Compare commits

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

* fix(cli): type model display metadata

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

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

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

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

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

* fix(sdk): constrain binary tool result truncation

* style(sdk): remove disallowed comment formatting

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

This reverts commit 9d8b66726d.

---------

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

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

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

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

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

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

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

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

* chore(onboarding): tighten inline comments

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

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

* fix(onboarding): add userType to handleFooterAction deps

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

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

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

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

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

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

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

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

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

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

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

* chore(onboarding): trim redundant inline comments

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

* chore: trim featureFlags.ts comment

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

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

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

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-06-18 14:45:34 -07:00
Tomás Barreiro 6fe5acb2a4 Resolve feature flags using the user id on startup (#11652) 2026-06-18 23:24:42 +02:00
Tomás Barreiro 99fb5101e5 Improve ClinePass error handling (#11637)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Add ClinePass to the onboarding screen

* Fix the auth flow not starting

* Hide option behind a feature flag

* Update icon

* Remove unrelated changes

* Hide the custom model id

* fix model names

* remove custom model id option

* fix tests

* Fix names

* Display the clinepass models in the onboarding

* Throw a specific error when the user isn't subscribed

* properly render the error message

* fix error detection

* refactor

* Update the ResponseErrorHandler type

* re-add trailing slash
2026-06-18 22:18:12 +02:00
Tomás Barreiro 58db9887ee Clear organization on ClinePass selection on the VSCode extension (#11648)
* Clear organization on ClinePass selection on the VSCode extension

* Disable remote config if ClinePass is selected

* Disable remote config if ClinePass is selected

* Revert formatting changes

* fix issue
2026-06-18 22:04:49 +02:00
Ahmad Shahzad b16a28cd6f feat(vscode): add Fireworks GLM 5.2 and Kimi K2.6 Fast, fix DeepSeek V4 Flash cache-read price (#11642)
Update the Fireworks model registry in apps/vscode/src/shared/api.ts:

- Add accounts/fireworks/models/glm-5p2: new general-purpose model with
  a 1,048,576-token context window (131,072 output).
- Add accounts/fireworks/routers/kimi-k2p6-fast: standardizes the
  Kimi K2.6 router on the `-fast` naming convention used for /routers.
  The existing kimi-k2p6-turbo entry is retained for now to avoid
  breaking user workflows; the turbo variant is expected to be removed
  in a future release once the fast variant is the only one served.
- Correct the cache-read price for accounts/fireworks/models/deepseek-v4-flash
  from 0.03 to 0.028 to match the published rate.

Pricing sourced from https://docs.fireworks.ai/serverless/pricing.
2026-06-18 21:18:01 +02:00
114aa3f161 Add friendly Cline Pass entitlement error UI (#11606)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* Add friendly Cline Pass entitlement error UI

When a Cline Pass model returns a 403 ENTITLEMENT_ERROR (user not subscribed to the required model plan), the chat dumped the raw serialized error JSON and pointlessly auto-retried it.

- Add ClineErrorType.Entitlement, classified before the generic 403/auth path.
- Skip auto-retry (and the retries-exhausted message) for entitlement errors.
- Render a dedicated EntitlementError card: friendly headline, env-aware 'Get Cline Pass' subscribe link (clineUser.appBaseUrl, falling back to production), and a Retry button; backend detail is shown as muted support text.
- Add unit/component tests and a Storybook story.

* Scope entitlement to individual case; fix path-prefixed subscribe URL

- ClineError: only classify the individual 'not subscribed' ENTITLEMENT_ERROR as Entitlement (checks message and details.message). Org-account variant falls through to generic handling rather than showing a misleading 'Get Cline Pass' card.

- EntitlementError: build the subscribe URL with a relative path against a trailing-slash-normalized base so path-prefixed self-hosted/proxy app URLs (e.g. https://proxy.example.com/cline/app) are preserved instead of resetting to origin.

- Tests: add org-exclusion case, path-prefixed URL case, and assert the retry askResponse payload (yesButtonClicked).

* Trim redundant inline comments

* Add provider test: pre-stream 403 entitlement error classifies correctly

Documents and locks in that a 403 ENTITLEMENT_ERROR (which rejects completions.create before streaming, as an OpenAI SDK APIError) propagates through ClineHandler with the code intact so ClineError classifies it as Entitlement. Confirms the error does not rely on the mid-stream chunk.error path.

* Guard subscribe URL build against malformed appBaseUrl

Wrap new URL() in try/catch so an invalid appBaseUrl from the auth context omits the subscribe link instead of throwing TypeError and crashing the EntitlementError card mid-render. Addresses Greptile review feedback; adds a malformed-URL test.

* test: trim entitlement error UI coverage

* Use one-word 'ClinePass' in user-facing copy

Renames the display text in the entitlement card (headline, helper, button) and related tests/story/comments from 'Cline Pass' to 'ClinePass'. Also normalizes EntitlementError.test.tsx formatting to the webview Biome style.

* fix: skip subagent retries for ClinePass entitlement errors

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-06-18 12:06:40 -07:00
Tomás Barreiro 64743d2911 Improve the ClinePass UX (#11644) 2026-06-18 20:39:52 +02:00
Tomás Barreiro d7292993e9 Add Cline Pass to the extension (#11556)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* refactor

* remove redundancy

* fix syncModeConfigurations
2026-06-18 20:19:46 +02:00
Tomás Barreiro 8110cc46d8 Add the ClinePass onboarding flow (#11590)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Add ClinePass to the onboarding screen

* Fix the auth flow not starting

* Hide option behind a feature flag

* Update icon

* Remove unrelated changes

* Hide the custom model id

* fix model names

* remove custom model id option

* fix tests

* Fix names

* Display the clinepass models in the onboarding
2026-06-18 19:00:18 +02:00
Saoud Rizwan a3cd39da14 feat(cli): update hub dashboard design with customizations break out (#11631)
* feat(cli): refresh hub dashboard navigation

* fix(cli): address hub dashboard review feedback

* feat(cli): polish hub dashboard mockup styling

* fix(cli): polish hub dashboard pages

* fix(cli): brand hub browser metadata

* fix(cli): simplify marketplace entry details

* fix(cli): consolidate customization catalog lists

* fix(cli): dedupe marketplace installed rows

* fix(cli): preserve marketplace controls in consolidated rows

* fix(cli): align marketplace plugin controls

* fix(cli): flatten marketplace plugin tool layout

* fix(cli): place marketplace path below tags

* fix(cli): show primitive icons in marketplace rows

* fix(cli): sort sessions by displayed created time

* fix(cli): restore hub account navigation

* fix(cli): refine hub account navigation

* fix(cli): reuse hub account credentials

* fix(cli): support uninstalling local primitives
2026-06-17 21:55:31 -07:00
156 changed files with 9771 additions and 4000 deletions
+24
View File
@@ -6,6 +6,15 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"CLINE_DIR",
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
@@ -38,6 +47,9 @@ describe("runDashboardCommand", () => {
let observedEnv:
| {
workspaceRoot: string | undefined;
clineDir: string | undefined;
clineDataDir: string | undefined;
providerSettingsPath: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
@@ -50,7 +62,9 @@ describe("runDashboardCommand", () => {
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
configDir: "/tmp/cline-config",
cwd: "sdk",
dataDir: ".cline-dashboard-data",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
@@ -62,6 +76,9 @@ describe("runDashboardCommand", () => {
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
clineDir: process.env.CLINE_DIR,
clineDataDir: process.env.CLINE_DATA_DIR,
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
@@ -87,6 +104,13 @@ describe("runDashboardCommand", () => {
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
clineDir: "/tmp/cline-config",
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
providerSettingsPath: join(
resolve("sdk", ".cline-dashboard-data"),
"settings",
"providers.json",
),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
+27 -7
View File
@@ -3,6 +3,7 @@ import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { configureSandboxEnvironment } from "../utils/helpers";
import { c } from "../utils/output";
export interface DashboardServerHandle {
@@ -19,7 +20,9 @@ interface DashboardCommandIo {
}
export interface RunDashboardCommandOptions {
configDir?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -36,10 +39,9 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value === undefined) {
return () => {};
if (value !== undefined) {
process.env[name] = value;
}
process.env[name] = value;
return () => {
if (previous === undefined) {
delete process.env[name];
@@ -49,21 +51,39 @@ function setEnvValue(name: string, value: string | undefined): () => void {
};
}
const SANDBOX_ENV_KEYS = [
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
] as const;
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const restore = [
setEnvValue(
"WORKSPACE_ROOT",
options.cwd ? resolve(options.cwd) : undefined,
),
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
];
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
configureSandboxEnvironment({
enabled: true,
cwd,
explicitDir: options.dataDir,
});
}
try {
return await fn();
} finally {
+39
View File
@@ -114,6 +114,7 @@ const telemetryMocks = vi.hoisted(() => ({
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
}));
function forcePromptModeInput() {
@@ -179,6 +180,8 @@ vi.mock("./utils/feature-flags", () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
@@ -252,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
providerSettingsMocks.getProviderSettings.mockReset();
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
providerSettingsMocks.saveProviderSettings.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -912,6 +918,33 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: {
accountId: "acct-startup",
accessToken: "workos:token",
refreshToken: "refresh-token",
},
};
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
@@ -929,6 +962,10 @@ describe("runCli lightweight command dispatch", () => {
"bun",
"src/index.ts",
"dashboard",
"--config",
"/tmp/cline-config",
"--data-dir",
".cline-dashboard-data",
"--port",
"9090",
"--no-open",
@@ -939,6 +976,8 @@ describe("runCli lightweight command dispatch", () => {
await expect(runCli()).resolves.toBeUndefined();
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
configDir: "/tmp/cline-config",
dataDir: ".cline-dashboard-data",
port: "9090",
openBrowser: false,
io: expect.any(Object),
+16
View File
@@ -22,6 +22,7 @@ import {
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
@@ -595,7 +596,12 @@ export async function runCli(): Promise<void> {
const dashboardCmd = program
.command("dashboard")
.description("Start the Cline Hub dashboard and open it in a browser")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Workspace root", process.cwd())
.option(
"--data-dir <dir>",
"Use isolated local state at <dir> instead of ~/.cline (enables sandbox mode)",
)
.option("--host <host>", "Dashboard bind host")
.option("--port <port>", "Dashboard HTTP/WebSocket port")
.option("--public-url <url>", "Public dashboard URL")
@@ -603,7 +609,9 @@ export async function runCli(): Promise<void> {
.option("--no-open", "Start the dashboard without opening a browser")
.action(async () => {
const opts = dashboardCmd.opts<{
config?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -612,7 +620,9 @@ export async function runCli(): Promise<void> {
}>();
const { runDashboardCommand } = await import("./commands/dashboard");
ctx.exitCode = await runDashboardCommand({
configDir: opts.config,
cwd: opts.cwd,
dataDir: opts.dataDir,
host: opts.host,
port: opts.port,
publicUrl: opts.publicUrl,
@@ -910,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,
});
+101
View File
@@ -27,7 +27,18 @@ const outputMocks = vi.hoisted(() => ({
c: { dim: "", reset: "" },
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription/";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
vi.mock("@cline/core", () => ({
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -511,6 +522,39 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).toHaveBeenCalledWith("Missing API key");
});
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
error.name = "ClineNotSubscribedError";
sessionManagerMocks.start.mockRejectedValue(error);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("emits JSON error lines for non-completed results", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
@@ -576,6 +620,63 @@ describe("runAgent", () => {
);
});
it("renders ClinePass subscription errors with friendly copy for failed results", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+3 -2
View File
@@ -16,6 +16,7 @@ import {
requestToolApproval,
submitAndExitInTerminal,
} from "../utils/approval";
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
import { handleEvent, handleTeamEvent } from "../utils/events";
import { createRuntimeHooks } from "../utils/hooks";
import {
@@ -374,7 +375,7 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = result.text.trim();
const errorText = formatCliErrorMessage(result.text).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -395,7 +396,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = formatCliErrorMessage(err);
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
+2
View File
@@ -121,6 +121,7 @@ export async function runInteractive(
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy,
tuiToolApprover,
tuiAskQuestion,
} = createInteractiveApprovalController(config);
@@ -160,6 +161,7 @@ export async function runInteractive(
resumeSessionId,
chatCommandState,
requestToolApproval,
resolveToolPolicy,
askQuestionRef: tuiAskQuestion,
resolveMistakeLimitDecision,
switchToActModeTool,
+38 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "./tool-policies";
describe("tool policy helpers", () => {
@@ -53,9 +54,9 @@ describe("tool policy helpers", () => {
});
});
it("restores the baseline policies when toggled back on", () => {
it("forces all baseline policies to auto-approve when toggled back on", () => {
const baseline = {
"*": { autoApprove: true },
"*": { autoApprove: false },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: false, enabled: true },
};
@@ -72,6 +73,40 @@ describe("tool policy helpers", () => {
enabled: true,
});
expect(target).toEqual(baseline);
expect(target).toEqual({
"*": { autoApprove: true },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: true, enabled: true },
});
});
it("resolves live per-tool policies from the interactive auto-approve state", () => {
const baseline = {
"*": { autoApprove: false },
read_files: { enabled: true },
editor: { autoApprove: false, enabled: true },
};
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "editor",
baselinePolicies: baseline,
enabled: true,
}),
).toEqual({ autoApprove: true, enabled: true });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "run_commands",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: false });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "read_files",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: true, enabled: true });
});
});
+35 -7
View File
@@ -27,21 +27,51 @@ export function cloneToolPolicies(
);
}
export function resolveInteractiveAutoApprovePolicy(input: {
toolName: string;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): ToolPolicy {
const toolPolicy = input.baselinePolicies[input.toolName] ?? {};
const baselinePolicy = {
...(input.baselinePolicies["*"] ?? {}),
...toolPolicy,
};
return {
...baselinePolicy,
autoApprove: input.enabled
? true
: SAFE_AUTO_APPROVE_TOOLS.has(input.toolName)
? (toolPolicy.autoApprove ?? true)
: false,
};
}
export function applyInteractiveAutoApproveOverride(input: {
targetPolicies: Record<string, ToolPolicy>;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): void {
const nextPolicies: Record<string, ToolPolicy> = input.enabled
? cloneToolPolicies(input.baselinePolicies)
? Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: true,
},
]),
)
: Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: SAFE_AUTO_APPROVE_TOOLS.has(name)
? (policy.autoApprove ?? true)
: false,
autoApprove: resolveInteractiveAutoApprovePolicy({
toolName: name,
baselinePolicies: input.baselinePolicies,
enabled: false,
}).autoApprove,
},
]),
);
@@ -53,9 +83,7 @@ export function applyInteractiveAutoApproveOverride(input: {
}
const globalPolicy = clonePolicy(nextPolicies["*"]);
globalPolicy.autoApprove = input.enabled
? (input.baselinePolicies["*"]?.autoApprove ?? true)
: false;
globalPolicy.autoApprove = input.enabled;
nextPolicies["*"] = globalPolicy;
for (const key of Object.keys(input.targetPolicies)) {
@@ -2,6 +2,10 @@ import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClinePassSubscriptionUrl,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
@@ -290,6 +294,41 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
);
}
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getClinePassSubscriptionUrl();
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="yellow"
paddingX={1}
>
<text fg="yellow">ClinePass subscription required</text>
<text
fg={props.defaultFg}
selectable
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>Open subscription page</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">URL: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -388,6 +427,9 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -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;
}
@@ -134,6 +134,7 @@ export function ModelSelectorContent(
currentModel: string;
currentProviderName: string;
models: ModelOption[];
showCustomModelId?: boolean;
},
) {
const {
@@ -143,6 +144,7 @@ export function ModelSelectorContent(
currentModel,
currentProviderName,
models,
showCustomModelId = true,
} = props;
const [search, setSearch] = useState("");
const [selected, setSelected] = useState(() => {
@@ -164,7 +166,7 @@ export function ModelSelectorContent(
return scored.map((r) => r.model);
}, [models, search]);
const optionCount = filtered.length + 1;
const optionCount = filtered.length + (showCustomModelId ? 1 : 0);
const safeSelected = Math.min(selected, Math.max(0, optionCount - 1));
useDialogKeyboard((key) => {
@@ -188,7 +190,7 @@ export function ModelSelectorContent(
resolve(model.key);
return;
}
if (safeSelected === filtered.length) {
if (showCustomModelId && safeSelected === filtered.length) {
setIsCreatingCustomModel(true);
setCustomModelId("");
setCustomModelError("");
@@ -290,6 +292,7 @@ export function ModelSelectorContent(
dimmed={onProvider}
currentModel={currentModel}
onSelect={resolve}
showCustomModelId={showCustomModelId}
onCreateCustomModel={() => {
setIsCreatingCustomModel(true);
setCustomModelId("");
@@ -408,6 +411,7 @@ function ModelList(props: {
dimmed?: boolean;
currentModel: string;
onSelect: (key: string) => void;
showCustomModelId: boolean;
onCreateCustomModel: () => void;
}) {
const {
@@ -416,11 +420,12 @@ function ModelList(props: {
dimmed,
currentModel,
onSelect,
showCustomModelId,
onCreateCustomModel,
} = props;
const rows: ({ type: "model"; model: ModelOption } | { type: "custom" })[] = [
...items.map((model) => ({ type: "model" as const, model })),
{ type: "custom" as const },
...(showCustomModelId ? ([{ type: "custom" as const }] as const) : []),
];
if (rows.length <= MAX_VISIBLE) {
@@ -3,6 +3,8 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
resolveModelMaxInputTokens,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -70,3 +72,31 @@ describe("formatStatusBarUsageText", () => {
).toBe("(12,345)");
});
});
describe("model display helpers", () => {
it("resolves status-bar model names by model slug when provider prefixes differ", () => {
expect(
resolveModelDisplayName({
modelId: "zai/glm-5.2",
knownModels: {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "GLM-5.2" },
},
}),
).toBe("GLM-5.2");
});
it("resolves max input tokens by model slug when provider prefixes differ", () => {
expect(
resolveModelMaxInputTokens({
modelId: "zai/glm-5.2",
knownModels: {
"z-ai/glm-5.2": {
id: "z-ai/glm-5.2",
name: "GLM-5.2",
maxInputTokens: 262_144,
},
},
}),
).toBe(262_144);
});
});
+9 -25
View File
@@ -11,6 +11,11 @@ import {
getSuccessColor,
} from "../palette";
import { HOME_VIEW_MAX_WIDTH } from "../types";
import {
type KnownModels,
resolveModelDisplayName as resolveKnownModelDisplayName,
resolveKnownModelInfo,
} from "./model-selector/model-display-name";
export function createContextBar(
used: number,
@@ -56,31 +61,13 @@ export function formatStatusBarUsageText(input: {
return `${tokens} ${formatCost(input.totalCost)}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
// may include a provider prefix ("anthropic/claude-sonnet-4-6"), so we
// try the full ID first, then strip the prefix and retry.
function lookupModelInfo(
modelId: string,
knownModels?: Record<string, unknown>,
): { name?: string } | undefined {
if (!knownModels) return undefined;
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit) return hit;
}
return undefined;
}
export function resolveModelDisplayName(config: {
modelId: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
thinking?: boolean;
reasoningEffort?: string;
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
const name = resolveKnownModelDisplayName(config.modelId, config.knownModels);
if (config.thinking && config.reasoningEffort) {
return `${name} (${config.reasoningEffort})`;
}
@@ -89,12 +76,9 @@ export function resolveModelDisplayName(config: {
export function resolveModelMaxInputTokens(config: {
modelId: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
}): number | undefined {
const info = (lookupModelInfo(config.modelId, config.knownModels) ?? {}) as {
maxInputTokens?: number;
contextWindow?: number;
};
const info = resolveKnownModelInfo(config.modelId, config.knownModels) ?? {};
if (typeof info.maxInputTokens === "number" && info.maxInputTokens > 0) {
return info.maxInputTokens;
}
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
const [uiMode, setUiMode] = useState<AgentMode>(
config.mode === "plan" ? "plan" : "act",
);
const [autoApproveAll, _setAutoApproveAll] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
const autoApproveAllRef = useRef(initialAutoApproveAll);
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
}, []);
const toggleAutoApprove = useCallback(() => {
_setAutoApproveAll((prev) => {
const next = !prev;
onAutoApproveChange(next);
return next;
});
const next = !autoApproveAllRef.current;
autoApproveAllRef.current = next;
onAutoApproveChange(next);
_setAutoApproveAll(next);
}, [onAutoApproveChange]);
const setCompactionMode = useCallback(
+5 -1
View File
@@ -4,6 +4,7 @@ import type {
PendingPromptSnapshot,
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
@@ -171,7 +172,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({ kind: "error", text: event.error.message });
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error),
});
}
break;
case "notice":
@@ -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())
}
@@ -323,6 +323,7 @@ export function useModelSelector(opts: {
currentModel={config.modelId}
currentProviderName={providerDisplayName}
models={modelOptions}
showCustomModelId={config.providerId !== "cline-pass"}
/>
),
});
@@ -413,6 +414,7 @@ export function useModelSelector(opts: {
currentModel={config.modelId}
currentProviderName={providerDisplayName}
models={modelOptions}
showCustomModelId={config.providerId !== "cline-pass"}
/>
),
});
@@ -1,4 +1,5 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
import {
@@ -376,7 +377,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: error instanceof Error ? error.message : String(error),
text: formatCliErrorMessage(error),
});
}
} finally {
@@ -5,6 +5,7 @@ const hoisted = vi.hoisted(() => ({
startClineDeviceAuth: vi.fn(),
completeClineDeviceAuth: vi.fn(),
saveLocalProviderOAuthCredentials: vi.fn(),
identifyFeatureFlagsAccount: vi.fn(async () => {}),
openMock: vi.fn(() => Promise.resolve()),
}));
@@ -22,6 +23,10 @@ vi.mock("@cline/shared", () => ({
vi.mock("open", () => ({ default: hoisted.openMock }));
vi.mock("../../../utils/feature-flags", () => ({
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
}));
import { runDeviceCodeAuthFlow, runOAuthAuthFlow } from "./auth";
// Minimal stand-in for a telemetry service. The auth helpers must forward this
@@ -45,6 +50,8 @@ describe("onboarding auth telemetry forwarding", () => {
hoisted.startClineDeviceAuth.mockReset();
hoisted.completeClineDeviceAuth.mockReset();
hoisted.saveLocalProviderOAuthCredentials.mockReset();
hoisted.identifyFeatureFlagsAccount.mockReset();
hoisted.identifyFeatureFlagsAccount.mockResolvedValue(undefined);
hoisted.openMock.mockReset();
hoisted.openMock.mockResolvedValue(undefined);
});
@@ -85,6 +92,7 @@ describe("onboarding auth telemetry forwarding", () => {
// Identity, not deep-equal — we are validating the exact reference flows
// through so opt-out / common metadata stays consistent.
expect(telemetryArg).toBe(fakeTelemetry);
expect(hoisted.identifyFeatureFlagsAccount).not.toHaveBeenCalled();
});
it("does not pass telemetry when none is provided (back-compat)", () => {
@@ -121,6 +129,8 @@ describe("onboarding auth telemetry forwarding", () => {
access: "a",
refresh: "r",
expires: 0,
accountId: "acct-1",
email: "user@example.com",
});
runDeviceCodeAuthFlow({
@@ -146,6 +156,10 @@ describe("onboarding auth telemetry forwarding", () => {
// emitted by completeClineDeviceAuth, so passing telemetry to the start
// helper would double-emit the event.
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
expect(hoisted.identifyFeatureFlagsAccount).toHaveBeenCalledWith({
id: "acct-1",
email: "user@example.com",
});
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
+17
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import open from "open";
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
export type OnboardingOAuthProviderId = string;
@@ -18,6 +19,10 @@ export function isOnboardingOAuthProviderId(
return isOAuthProvider(providerId);
}
function isClineAccountOAuthProvider(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export function runOAuthAuthFlow(input: {
providerId: OnboardingOAuthProviderId;
providerSettingsManager: ProviderSettingsManager;
@@ -56,6 +61,12 @@ export function runOAuthAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -118,6 +129,12 @@ export function runDeviceCodeAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -9,12 +9,14 @@ import {
resolveProviderConfig,
saveLocalProviderSettings,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
@@ -23,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,
@@ -46,11 +49,13 @@ import {
import { FIELD_ORDER } from "./fields";
import { useOnboardingKeyboard } from "./keyboard";
import {
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
type OnboardingStep,
type ProviderEntry,
type ReasoningEffort,
shouldUseFeaturedClineModelPicker,
type ThinkingLevel,
toModelEntriesFromKnownModels,
toModelEntry,
@@ -71,6 +76,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
[props.providerSettingsManager],
);
const menuOptions = useMemo(
() =>
getMainMenuOptions({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
}),
[],
);
const [step, setStep] = useState<OnboardingStep>("menu");
const [menuSelected, setMenuSelected] = useState(0);
const [oauthProvider, setOauthProvider] = useState("");
@@ -153,6 +166,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const createCustomModelItem = useCallback(
(_search: string, filteredItems: SearchableItem[]) => {
if (activeProviderId === "cline-pass") {
return undefined;
}
if (filteredItems.some((item) => item.key === CUSTOM_MODEL_ID_ACTION)) {
return undefined;
}
@@ -163,7 +179,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
searchText: "create custom model id manual entry",
} satisfies SearchableItem;
},
[],
[activeProviderId],
);
const modelList = useSearchableList(modelItems, createCustomModelItem);
@@ -179,7 +195,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
Set<string>
>(new Set());
const [clineKnownModels, setClineKnownModels] = useState<
Record<string, unknown> | undefined
KnownModels | undefined
>(undefined);
useEffect(() => {
@@ -256,7 +272,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const provider = providers.find((p) => p.id === providerId);
setActiveProviderName(provider?.name ?? providerId);
setModelsDefaultId(provider?.defaultModelId ?? "");
if (providerId === "cline") {
if (shouldUseFeaturedClineModelPicker(providerId)) {
setClineModelSelected(0);
setStep("cline_model");
} else if (providerId === "openai-compatible") {
@@ -307,7 +323,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const startOAuthFlow = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline") {
if (isClineProvider(providerId)) {
startDeviceCodeFlow(providerId);
return;
}
@@ -611,6 +627,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
onExit: props.onExit,
oauthProvider,
activeProviderId,
menuOptions,
menuSelected,
providerList,
modelList,
@@ -685,6 +702,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
},
handleModelItemSelect: selectModelItem,
menuSelected,
menuOptions,
modelItems,
modelList,
modelsLoading,
+14 -6
View File
@@ -3,10 +3,13 @@ import { useKeyboard } from "@opentui/react";
import type { Dispatch, SetStateAction } from "react";
import type { ClineModelPickerEntry } from "../../components/model-selector/cline-model-picker";
import type { SearchableListState } from "../../components/searchable-list";
import type { OnboardingOAuthProviderId } from "./auth";
import {
isOnboardingOAuthProviderId,
type OnboardingOAuthProviderId,
} from "./auth";
import { FIELD_ORDER } from "./fields";
import {
MAIN_MENU,
type MenuOption,
type OnboardingStep,
THINKING_LEVELS,
type ThinkingLevel,
@@ -17,6 +20,7 @@ export function useOnboardingKeyboard(input: {
onExit: () => void;
oauthProvider: string;
activeProviderId: string;
menuOptions: MenuOption[];
menuSelected: number;
providerList: SearchableListState;
modelList: SearchableListState;
@@ -133,17 +137,21 @@ export function useOnboardingKeyboard(input: {
if (input.step === "menu") {
if (key.name === "up") {
input.setMenuSelected((s) => (s <= 0 ? MAIN_MENU.length - 1 : s - 1));
input.setMenuSelected((s) =>
s <= 0 ? input.menuOptions.length - 1 : s - 1,
);
return;
}
if (key.name === "down") {
input.setMenuSelected((s) => (s >= MAIN_MENU.length - 1 ? 0 : s + 1));
input.setMenuSelected((s) =>
s >= input.menuOptions.length - 1 ? 0 : s + 1,
);
return;
}
if (key.name === "return") {
const option = MAIN_MENU[input.menuSelected];
const option = input.menuOptions[input.menuSelected];
if (!option) return;
if (option.value === "cline" || option.value === "openai-codex") {
if (isOnboardingOAuthProviderId(option.value)) {
input.startOAuthFlow(option.value);
} else {
input.setStep("byo_provider");
@@ -1,12 +1,30 @@
import { describe, expect, it } from "vitest";
import {
getMainMenuOptions,
getOAuthProviderLabel,
shouldUseFeaturedClineModelPicker,
toModelEntriesFromKnownModels,
toModelEntry,
toProviderEntry,
} from "./model";
describe("onboarding model helpers", () => {
it("hides ClinePass from the main menu unless its feature flag is enabled", () => {
expect(
getMainMenuOptions().some((option) => option.value === "cline-pass"),
).toBe(false);
expect(
getMainMenuOptions({ isClinePassEnabled: false }).some(
(option) => option.value === "cline-pass",
),
).toBe(false);
expect(
getMainMenuOptions({ isClinePassEnabled: true }).some(
(option) => option.value === "cline-pass",
),
).toBe(true);
});
it("maps provider catalog entries into onboarding provider entries", () => {
expect(
toProviderEntry({
@@ -112,7 +130,14 @@ describe("onboarding model helpers", () => {
it("formats OAuth provider labels for onboarding status views", () => {
expect(getOAuthProviderLabel("cline")).toBe("Cline");
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
expect(getOAuthProviderLabel("openai-codex")).toBe("ChatGPT");
expect(getOAuthProviderLabel("oca")).toBe("oca");
});
it("uses the featured Cline model picker only for the Cline provider", () => {
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
@@ -43,6 +43,12 @@ export const MAIN_MENU: MenuOption[] = [
detail: "Latest models with regular free promos",
icon: "\u263a",
},
{
label: "Sign in with ClinePass",
value: "cline-pass",
detail: "Low cost subscription for everyone",
icon: "\u2726",
},
{
label: "Sign in with ChatGPT",
value: "openai-codex",
@@ -57,6 +63,14 @@ export const MAIN_MENU: MenuOption[] = [
},
];
export function getMainMenuOptions(options?: {
isClinePassEnabled?: boolean;
}): MenuOption[] {
return MAIN_MENU.filter(
(option) => option.value !== "cline-pass" || options?.isClinePassEnabled,
);
}
export interface OnboardingResult {
providerId: string;
modelId: string;
@@ -139,6 +153,9 @@ export function toModelEntriesFromKnownModels(
}
export function getOAuthProviderLabel(providerId: string): string {
if (providerId === "cline-pass") {
return "ClinePass";
}
if (providerId === "cline") {
return "Cline";
}
@@ -147,3 +164,7 @@ export function getOAuthProviderLabel(providerId: string): string {
}
return providerId;
}
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
return providerId === "cline";
}
@@ -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,
@@ -20,7 +21,7 @@ import {
import { useTerminalBackground } from "../../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../../palette";
import { FIELD_ORDER } from "./fields";
import { MAIN_MENU, THINKING_LEVELS } from "./model";
import { type MenuOption, THINKING_LEVELS } from "./model";
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
@@ -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;
@@ -633,6 +634,7 @@ export function OnboardingThinkingLevelScreen(props: {
export function OnboardingMainMenuScreen(props: {
contentWidth: number;
menuOptions: MenuOption[];
menuSelected: number;
mouse: MouseTrackerState;
}) {
@@ -671,7 +673,7 @@ export function OnboardingMainMenuScreen(props: {
marginTop={1}
gap={0}
>
{MAIN_MENU.map((option, i) => {
{props.menuOptions.map((option, i) => {
const isSel = i === props.menuSelected;
return (
<box
@@ -166,6 +166,7 @@ export function OnboardingView(props: OnboardingViewProps) {
return (
<OnboardingMainMenuScreen
contentWidth={contentWidth}
menuOptions={state.menuOptions}
menuSelected={state.menuSelected}
mouse={mouse}
/>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClinePassSubscriptionUrl,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
describe("cline-pass-errors", () => {
it("recognizes both raw and formatted ClinePass subscription messages", () => {
expect(
isClinePassSubscriptionError(
"the user is not subscribed to required model plan",
),
).toBe(true);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription/",
);
});
});
+40
View File
@@ -0,0 +1,40 @@
import {
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
} from "@cline/core";
export { getClinePassSubscriptionUrl };
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized.includes("no access to clinepass subscription models yet") &&
normalized.includes("subscribe to clinepass")
);
}
export function isClinePassSubscriptionError(error: unknown): boolean {
if (isClineNotSubscribedError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineNotSubscribedError" ||
isClineNotSubscribedMessage(error.message) ||
isFormattedClinePassSubscriptionMessage(error.message)
);
}
return (
typeof error === "string" &&
(isClineNotSubscribedMessage(error) ||
isFormattedClinePassSubscriptionMessage(error))
);
}
export function formatCliErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
+12 -5
View File
@@ -87,22 +87,29 @@ export async function disposeCliFeatureFlagsService(): Promise<void> {
await current.dispose();
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
export function setCliFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): void {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
setCliFeatureFlagsAccountContext(account);
if (!cliFeatureFlagsService) {
return;
}
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
try {
await cliFeatureFlagsService.poll();
} catch (error) {
+56 -4
View File
@@ -4,15 +4,20 @@ import {
ClineAccountService,
ensureCustomProvidersLoaded,
executeClineAccountAction,
formatProviderOAuthApiKey,
getLocalProviderModels,
getPersistedProviderApiKey,
getProviderOAuthCredentialsFromSettings,
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
type ProviderProtocol,
type ProviderSettings,
readGlobalSettings,
resolveLocalClineAuthToken,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
@@ -30,6 +35,7 @@ import { providerSettingsManager, workspaceRoot } from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
@@ -57,6 +63,39 @@ const ROUTINE_SCHEDULE_COMMANDS = new Set([
"delete_routine_schedule",
]);
async function resolveHubClineAccountAuthToken(input: {
settings?: ProviderSettings;
apiBaseUrl: string;
}): Promise<string | undefined> {
const credentials = input.settings
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
: null;
if (!credentials || !input.settings) {
return getPersistedProviderApiKey("cline", input.settings);
}
const nextCredentials = await getValidClineCredentials(credentials, {
apiBaseUrl: input.apiBaseUrl,
});
if (!nextCredentials) {
throw new Error(
"Cline account requires re-authentication. Run cline auth cline.",
);
}
if (nextCredentials !== credentials) {
saveLocalProviderOAuthCredentials(
providerSettingsManager,
"cline",
input.settings,
nextCredentials,
{ setLastUsed: false },
);
}
return formatProviderOAuthApiKey("cline", nextCredentials);
}
export async function handleDesktopCommand(
ctx: HubContext,
command: string,
@@ -133,10 +172,18 @@ export async function handleDesktopCommand(
}
if (command === "cline_account") {
const settings = providerSettingsManager.getProviderSettings("cline");
const apiBaseUrl =
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const authToken = await resolveHubClineAccountAuthToken({
settings,
apiBaseUrl,
});
if (!authToken) {
throw new Error("No Cline account auth token found");
}
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
apiBaseUrl,
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -234,6 +281,11 @@ export async function handleDesktopCommand(
broadcastHubState(ctx);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
broadcastHubState(ctx);
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
+26 -1
View File
@@ -3,11 +3,27 @@ import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
describe("isWebviewRoute", () => {
it.each([
"/",
"/chat",
"/sessions",
"/models",
"/customizations",
"/rules",
"/hooks",
"/mcp",
"/plugins",
"/skills",
"/agents",
"/tools",
"/marketplace",
"/marketplace/mcp",
"/marketplace/skills",
"/marketplace/plugins",
])("matches marketplace SPA route %s", (pathname) => {
"/channels",
"/schedules",
"/settings",
"/settings/providers",
])("matches dashboard SPA route %s", (pathname) => {
expect(isWebviewRoute(pathname)).toBe(true);
});
@@ -26,4 +42,13 @@ describe("normalizeWebviewIndexHtml", () => {
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
);
});
it("injects the persisted theme bootstrap once", () => {
const normalized = normalizeWebviewIndexHtml(
"<html><head></head><body></body></html>",
);
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
});
});
+32 -2
View File
@@ -22,6 +22,17 @@ const NO_STORE_HEADERS = {
};
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
(() => {
try {
const theme = window.localStorage.getItem("cline-hub-theme");
if (theme === "dark" || theme === "light") {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
}
} catch {}
})();
</script>`;
function contentTypeFor(path: string): string {
switch (extname(path)) {
@@ -49,23 +60,42 @@ export function isWebviewRoute(pathname: string): boolean {
pathname === "/" ||
pathname === "/index.html" ||
pathname === "/chat" ||
pathname === "/sessions" ||
pathname === "/models" ||
pathname === "/customizations" ||
pathname === "/rules" ||
pathname === "/hooks" ||
pathname === "/mcp" ||
pathname === "/plugins" ||
pathname === "/skills" ||
pathname === "/agents" ||
pathname === "/tools" ||
pathname === "/marketplace" ||
pathname === "/marketplace/mcp" ||
pathname === "/marketplace/skills" ||
pathname === "/marketplace/plugins" ||
pathname === "/channels" ||
pathname === "/schedules" ||
pathname === "/settings" ||
pathname.startsWith("/settings/")
);
}
export function normalizeWebviewIndexHtml(html: string): string {
return html.replaceAll('src="./', 'src="/').replaceAll('href="./', 'href="/');
const normalized = html
.replaceAll('src="./', 'src="/')
.replaceAll('href="./', 'href="/');
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
return normalized;
}
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
}
function renderDevIndexHtml(devServerUrl: string): string {
return `<!doctype html>
<html lang="en">
<head>
${THEME_BOOTSTRAP_SCRIPT}
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module">
@@ -76,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
<title>Cline Hub</title>
</head>
<body>
+74 -1
View File
@@ -1,5 +1,12 @@
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -9,6 +16,7 @@ import {
installMarketplaceEntry,
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntry,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
@@ -645,6 +653,67 @@ describe("marketplace installer", () => {
).toEqual({ installedKeys: [] });
});
it("uninstalls local MCP servers by name", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallLocalPrimitive({
type: "mcp",
id: "context7",
name: "context7",
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled context7.",
});
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
});
it("uninstalls local skills by removing their configured skill directory", async () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
await expect(
uninstallLocalPrimitive(
{
type: "skill",
id: "review",
name: "Review",
path: skillPath,
},
{ workspaceRoot },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Review.",
});
expect(existsSync(skillDir)).toBe(false);
});
it("reports official plugin marketplace entries installed from Cline home", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
@@ -676,6 +745,10 @@ describe("marketplace installer", () => {
});
it("does not report plugin inventory substring matches as installed", () => {
process.env.CLINE_DIR = mkdtempSync(
join(tmpdir(), "cline-marketplace-test-"),
);
expect(
listMarketplaceInstalledEntries(
{
+151 -3
View File
@@ -1,8 +1,27 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import {
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import {
deleteMcpServer,
@@ -12,6 +31,7 @@ import {
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
type MarketplaceEnvVar = {
name: string;
@@ -34,7 +54,7 @@ type MarketplaceInstallInput = {
type MarketplaceInstallResult = {
id: string;
type: MarketplacePrimitiveType;
type: LocalPrimitiveType;
status: "installed" | "uninstalled";
message: string;
details?: JsonRecord;
@@ -170,6 +190,42 @@ function readInstallRequest(args?: Record<string, unknown>) {
};
}
function readLocalUninstallInput(args?: Record<string, unknown>): {
id: string;
type: LocalPrimitiveType;
name?: string;
path?: string;
} {
const type = typeof args?.type === "string" ? args.type.trim() : "";
if (
type !== "mcp" &&
type !== "skill" &&
type !== "workflow" &&
type !== "plugin"
) {
throw new Error(
"local uninstall type must be mcp, skill, workflow, or plugin",
);
}
const id =
typeof args?.id === "string" && args.id.trim().length > 0
? args.id.trim()
: typeof args?.name === "string" && args.name.trim().length > 0
? args.name.trim()
: typeof args?.path === "string" && args.path.trim().length > 0
? args.path.trim()
: "";
if (!id) {
throw new Error("local uninstall id, name, or path is required");
}
return {
id,
type,
name: typeof args?.name === "string" ? args.name.trim() : undefined,
path: typeof args?.path === "string" ? args.path.trim() : undefined,
};
}
function readInstallInputList(
args?: Record<string, unknown>,
): MarketplaceInstallInput[] {
@@ -375,6 +431,98 @@ function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
return { command: "cline", argsPrefix: [] };
}
function isInsidePath(childPath: string, parentPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function resolveUserInstructionRemovalTarget(input: {
type: "skill" | "workflow";
path: string;
workspaceRoot?: string;
}): string {
const filePath = resolve(input.path);
const searchPaths =
input.type === "skill"
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
const containingRoot = searchPaths.find((root) =>
isInsidePath(filePath, root),
);
if (!containingRoot) {
throw new Error(
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
);
}
const stats = statSync(filePath, { throwIfNoEntry: false });
if (!stats?.isFile()) {
throw new Error(`${input.type} file does not exist: ${filePath}`);
}
if (input.type === "workflow") {
return filePath;
}
const skillDir = dirname(filePath);
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
}
export async function uninstallLocalPrimitive(
args?: Record<string, unknown>,
options: { workspaceRoot?: string } = {},
): Promise<MarketplaceInstallResult> {
const input = readLocalUninstallInput(args);
if (input.type === "mcp") {
const name = input.name ?? input.id;
const response = deleteMcpServer(name);
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
details: { mcp: response },
};
}
if (input.type === "plugin") {
const result = await uninstallLocalPlugin({
name: input.path ? undefined : (input.name ?? input.id),
path: input.path,
workspaceRoot: options.workspaceRoot,
});
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
details: result as unknown as JsonRecord,
};
}
if (input.type === "skill" || input.type === "workflow") {
if (!input.path) {
throw new Error(`${input.type} uninstall requires a path.`);
}
const target = resolveUserInstructionRemovalTarget({
type: input.type,
path: input.path,
workspaceRoot: options.workspaceRoot,
});
const stats = statSync(target, { throwIfNoEntry: false });
if (!stats) {
throw new Error(`${input.type} target does not exist: ${target}`);
}
rmSync(target, { recursive: stats.isDirectory(), force: true });
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${input.name ?? basename(target)}.`,
details: { path: target },
};
}
throw new Error(`Unsupported local uninstall type: ${input.type}`);
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
@@ -235,6 +235,7 @@ export function toWebviewSessionSummary(
providerId: session.provider,
model: session.model,
workspaceRoot: session.workspaceRoot,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
@@ -0,0 +1,70 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listUserInstructionConfigs } from "./user-instructions";
describe("listUserInstructionConfigs", () => {
const tempRoots: string[] = [];
const envSnapshot = {
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
};
afterEach(async () => {
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
tempRoots.length = 0;
});
it("uses the package name for package-backed plugin entries", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
const packageDir = join(
tempRoot,
".cline",
"plugins",
"_installed",
"git",
"github.com",
"demo",
"package",
);
await mkdir(packageDir, { recursive: true });
const pluginPath = join(packageDir, "index.ts");
await writeFile(
join(packageDir, "package.json"),
JSON.stringify(
{
name: "cline-sdk-portable-agents",
cline: {
plugins: [{ paths: ["./index.ts"] }],
},
},
null,
2,
),
);
await writeFile(pluginPath, "export default {};\n");
const data = await listUserInstructionConfigs(tempRoot);
const plugins = data.plugins as Array<{ name: string; path: string }>;
const plugin = plugins.find((item) => item.path === pluginPath);
expect(plugin?.name).toBe("cline-sdk-portable-agents");
});
});
+52 -2
View File
@@ -1,5 +1,13 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { extname, join, basename as pathBasename } from "node:path";
import {
dirname,
extname,
isAbsolute,
join,
basename as pathBasename,
relative,
resolve,
} from "node:path";
import {
createUserInstructionConfigService,
discoverPluginModulePaths,
@@ -17,6 +25,48 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
};
return typeof packageJson.name === "string" && packageJson.name.trim()
? packageJson.name.trim()
: undefined;
} catch {
return undefined;
}
}
function isPathWithin(parentPath: string, childPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
if (packageName) {
return packageName;
}
break;
}
const parent = resolve(current, "..");
if (parent === current) {
break;
}
current = parent;
}
return pathBasename(filePath, extname(filePath));
}
export async function listUserInstructionConfigs(
targetWorkspaceRoot: string,
): Promise<JsonRecord> {
@@ -115,7 +165,7 @@ export async function listUserInstructionConfigs(
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) continue;
pluginsByPath.set(filePath, {
name: pathBasename(filePath, extname(filePath)),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
});
+1
View File
@@ -109,6 +109,7 @@ export type WebviewSessionSummary = {
providerId?: string;
model?: string;
workspaceRoot?: string;
createdAt?: number;
updatedAt?: number;
inputTokens?: number;
outputTokens?: number;
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webview</title>
<title>Cline Hub</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -10,7 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@streamdown/cjk": "^1.0.3",
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

File diff suppressed because it is too large Load Diff
@@ -32,7 +32,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
"group/accordion-trigger relative flex flex-1 cursor-pointer items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className,
)}
{...props}
@@ -9,7 +9,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -24,7 +24,10 @@ function ComboboxTrigger({
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
className={cn(
"cursor-pointer data-disabled:cursor-not-allowed [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
@@ -140,7 +143,7 @@ function ComboboxItem({
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -250,7 +253,7 @@ function ComboboxChip({
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
className="-ml-1 cursor-pointer opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
@@ -152,7 +152,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
"group/command-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
@@ -90,7 +90,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
@@ -115,7 +115,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -164,7 +164,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -205,7 +205,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -56,7 +56,7 @@ function MenubarTrigger({
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
"flex cursor-pointer items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className,
)}
{...props}
@@ -120,7 +120,7 @@ function MenubarCheckboxItem({
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
@@ -155,7 +155,7 @@ function MenubarRadioItem({
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -23,7 +23,7 @@ function NativeSelect({
<select
data-slot="native-select"
data-size={size}
className="h-full w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
className="h-full w-full min-w-0 cursor-pointer appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon
@@ -18,7 +18,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 cursor-pointer rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -38,7 +38,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-fit cursor-pointer items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -117,7 +117,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -157,7 +157,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"top-0 z-10 flex w-full cursor-pointer items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -175,7 +175,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"bottom-0 z-10 flex w-full cursor-pointer items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -16,7 +16,7 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
"peer group/switch relative inline-flex cursor-pointer shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
@@ -58,7 +58,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import type { ComponentType, ReactNode } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type PageFrameProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
};
export function PageFrame({
children,
className,
contentClassName,
}: PageFrameProps) {
return (
<ScrollArea className="h-full">
<div
className={cn(
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
}
type PageHeaderProps = {
actions?: ReactNode;
className?: string;
description?: ReactNode;
icon?: ComponentType<{ className?: string }>;
meta?: ReactNode;
title: ReactNode;
};
export function PageHeader({
actions,
className,
description,
icon: Icon,
meta,
title,
}: PageHeaderProps) {
return (
<section
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
className,
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
{title}
</h1>
{meta}
</div>
{description ? (
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
{actions}
</div>
) : null}
</section>
);
}
type PageEmptyStateProps = {
children: ReactNode;
className?: string;
};
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
return (
<div
className={cn(
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
type CommandBadgeProps = {
children: ReactNode;
className?: string;
};
export function CommandBadge({ children, className }: CommandBadgeProps) {
return (
<span
className={cn(
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
@@ -15,15 +15,18 @@ import {
CreditCard,
ExternalLink,
Loader2,
LogIn,
LogOut,
Plus,
Receipt,
RefreshCw,
UserCircleIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
function normalizeAccountViewError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
@@ -35,6 +38,16 @@ function normalizeAccountViewError(error: unknown): Error {
return error instanceof Error ? error : new Error(message);
}
function isAccountAuthError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("no cline account auth token found") ||
normalized.includes("requires re-authentication") ||
normalized.includes("auth token") ||
normalized.includes("unauthorized")
);
}
// ---------------------------------------------------------------------------
// Data fetching helpers via sidecar command
// ---------------------------------------------------------------------------
@@ -125,6 +138,9 @@ export function AccountView() {
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
"overview",
);
const [accountActionPending, setAccountActionPending] = useState<
"sign-in" | "sign-out" | null
>(null);
// Overview data
const [user, setUser] = useState<ClineAccountUser | null>(null);
@@ -155,6 +171,19 @@ export function AccountView() {
const [billingLoaded, setBillingLoaded] = useState(false);
const activeOrganization = organizations.find((org) => org.active) ?? null;
const resetAccountData = useCallback(() => {
setUser(null);
setBalance(null);
setOrganizationBalance(null);
setOrganizations([]);
setUsageTransactions([]);
setUsageLoaded(false);
setUsageError(null);
setPaymentTransactions([]);
setBillingLoaded(false);
setBillingError(null);
}, []);
// -- Overview fetch --
const loadOverview = useCallback(async () => {
setOverviewLoading(true);
@@ -175,17 +204,61 @@ export function AccountView() {
setOrganizationBalance(organizationBalanceData);
setOrganizations(orgsData);
} catch (err) {
resetAccountData();
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setOverviewLoading(false);
}
}, []);
}, [resetAccountData]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const signIn = async () => {
setAccountActionPending("sign-in");
setOverviewError(null);
try {
await desktopClient.invoke("run_provider_oauth_login", {
provider: "cline",
});
await loadOverview();
setActiveTab("overview");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
resetAccountData();
} finally {
setAccountActionPending(null);
}
};
const signOut = async () => {
setAccountActionPending("sign-out");
try {
await desktopClient.invoke("save_provider_settings", {
provider: "cline",
api_key: "",
settings: {
auth: {
accessToken: "",
refreshToken: "",
accountId: "",
},
},
});
resetAccountData();
setActiveTab("overview");
setOverviewError("No Cline account auth token found");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setAccountActionPending(null);
}
};
// -- Usage fetch (lazy on tab switch) --
const loadUsage = useCallback(async () => {
const generation = usageGenerationRef.current;
@@ -295,6 +368,48 @@ export function AccountView() {
</div>
);
const renderSignedOut = () => (
<div className="rounded-lg border border-border bg-card p-6">
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UserCircleIcon className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
Sign in to Cline
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Connect your Cline account to review credits, usage, billing, and
organization details from Cline Hub.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
disabled={accountActionPending !== null}
onClick={() => void signIn()}
type="button"
>
{accountActionPending === "sign-in" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
</Button>
<a
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
href="https://app.cline.bot"
rel="noopener noreferrer"
target="_blank"
>
Create account
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
</div>
);
const renderLoading = () => (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -302,24 +417,19 @@ export function AccountView() {
);
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Account</h2>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
<PageFrame>
<PageHeader
description="Review account, usage, billing, and organization details."
title="Account"
/>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => (
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => {
const disabled = !user && tab !== "overview";
return (
<button
disabled={disabled}
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
@@ -328,6 +438,8 @@ export function AccountView() {
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
disabled &&
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
)}
>
{tab}
@@ -335,243 +447,265 @@ export function AccountView() {
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
))}
</div>
);
})}
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError && renderError(overviewError, loadOverview)}
{!overviewLoading && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError &&
(isAccountAuthError(overviewError)
? renderSignedOut()
: renderError(overviewError, loadOverview))}
{!overviewLoading && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href="https://app.cline.bot/dashboard"
target="_blank"
rel="noopener noreferrer"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
Open dashboard
<ExternalLink className="h-3.5 w-3.5" />
</a>
<Button
className="h-8 rounded-md px-2.5 text-xs"
disabled={accountActionPending !== null}
onClick={() => void signOut()}
type="button"
variant="outline"
>
{accountActionPending === "sign-out" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOut className="h-3.5 w-3.5" />
)}
{accountActionPending === "sign-out"
? "Signing out"
: "Sign out"}
</Button>
</div>
</div>
</div>
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<a
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Credit
</a>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
)}
</div>
)}
{/* Organizations */}
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<a
href="https://app.cline.bot/onboarding?step=1"
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
Credit
</a>
</div>
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading &&
!usageError &&
usageLoaded &&
(usageTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
{/* Organizations */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
</h3>
</div>
<a
href="https://app.cline.bot/onboarding?step=1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
</a>
</div>
))}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading &&
!usageError &&
usageLoaded &&
(usageTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
))}
</div>
)}
</div>
</ScrollArea>
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</PageFrame>
);
}
@@ -13,8 +13,8 @@ import {
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
const CAPABILITY_OPTIONS = [
"streaming",
@@ -204,9 +204,11 @@ export function AddProviderContent({
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center gap-3">
<PageFrame contentClassName="max-w-4xl">
<PageHeader
description="Add an OpenAI-compatible provider and choose its available models."
title="Add Provider"
actions={
<Button
onClick={onBack}
variant="secondary"
@@ -214,320 +216,314 @@ export function AddProviderContent({
aria-label="Back to providers"
>
<ArrowLeft className="h-4 w-4" />
Providers
</Button>
<h2 className="text-lg font-semibold text-foreground">
Add Provider
</h2>
</div>
}
/>
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider Name
</Label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0
? "Type model ID and press Enter"
: ""
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
Provider Name
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
{cap.replace(/-/g, " ")}
<X className="h-3 w-3" />
</Button>
</span>
))}
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0 ? "Type model ID and press Enter" : ""
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
/>
</Button>
>
{cap.replace(/-/g, " ")}
</Button>
))}
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
)}
/>
</Button>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) =>
updateHeaderValue(key, e.target.value)
}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) => updateHeaderValue(key, e.target.value)}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
</ScrollArea>
</PageFrame>
);
}
@@ -23,7 +23,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -35,6 +34,12 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import {
CommandBadge,
PageEmptyState,
PageFrame,
PageHeader,
} from "../page-layout";
type ConnectorField = {
flag: string;
@@ -341,16 +346,13 @@ export function ChannelsContent() {
};
return (
<ScrollArea className="h-full">
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<p className="text-sm text-muted-foreground">
{activeConnectors.length} connected
</p>
</div>
<div className="flex items-center gap-2">
<PageFrame>
<PageHeader
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline Hub.`}
title="Channels"
meta={<CommandBadge>cline connect</CommandBadge>}
actions={
<>
<Button
disabled={isLoading}
onClick={() => void refreshChannels()}
@@ -371,85 +373,81 @@ export function ChannelsContent() {
<Plus className="size-4" />
Add Channel
</Button>
</div>
</>
}
/>
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{isLoading ? (
<PageEmptyState>Loading channels...</PageEmptyState>
) : activeConnectors.length === 0 ? (
<PageEmptyState>No channels connected.</PageEmptyState>
) : (
<section className="overflow-hidden rounded-lg border bg-card">
<div className="grid gap-2 p-2.5">
{isLoading ? (
<p className="px-1 py-4 text-[13px] text-muted-foreground">
Loading channels...
</p>
) : activeConnectors.length === 0 ? (
<p className="px-1 py-4 text-[13px] text-muted-foreground">
No channels connected.
</p>
) : (
activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
{activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
title={connector.baseUrl}
>
{connector.hubUrl}
{connector.baseUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
</span>
) : null}
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
</span>
{connector.connectionMode ? (
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
{connector.connectionMode}
</span>
{connector.connectionMode ? (
<span className="rounded-md border bg-background px-1.5 py-0.5">
{connector.connectionMode}
</span>
) : null}
</div>
) : null}
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))
)}
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))}
</div>
</section>
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
@@ -639,6 +637,6 @@ export function ChannelsContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
</PageFrame>
);
}
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -35,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
type McpTransportType = "stdio" | "sse" | "streamableHttp";
@@ -405,18 +405,24 @@ export function McpServersContent() {
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
MCP Servers
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
<PageFrame>
<PageHeader
description={
hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."
}
title="MCP Servers"
meta={
<>
<CommandBadge>cline config mcp</CommandBadge>
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
From settings file
</span>
</div>
<div className="flex items-center gap-2">
</>
}
actions={
<>
<Button
variant="outline"
size="sm"
@@ -431,145 +437,133 @@ export function McpServersContent() {
<Plus className="h-4 w-4" />
Add MCP Server
</Button>
</div>
</>
}
/>
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
</div>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
<p className="mb-6 text-xs text-muted-foreground">
{hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."}
</p>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">
Command:
</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers &&
Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">
Headers:
</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">Command:</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers && Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">Headers:</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<Dialog
open={editorOpen}
onOpenChange={(open) => {
@@ -854,6 +848,6 @@ export function McpServersContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
</PageFrame>
);
}
@@ -2,6 +2,7 @@
import {
ArrowLeft,
ChevronRight,
Copy,
Eye,
EyeOff,
@@ -12,8 +13,8 @@ import {
PlusCircle,
RefreshCw,
Search,
Settings2,
Star,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -89,58 +90,142 @@ export function ProviderListContent({
onToggle,
onConfigure,
onAddProvider,
selectedProviderId,
variant = "page",
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
selectedProviderId?: string | null;
variant?: "page" | "panel";
}) {
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
const [providerSearch, setProviderSearch] = useState("");
const enabledProviderCount = providers.filter(
(provider) => provider.enabled,
).length;
const providerSearchQuery = providerSearch.trim().toLowerCase();
const filteredProviders = providerSearchQuery
? providers.filter((provider) =>
provider.name.toLowerCase().includes(providerSearchQuery),
)
: providers;
const isPanel = variant === "panel";
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Model Providers
</h2>
<Button
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
onClick={onAddProvider}
variant="ghost"
>
<PlusCircle className="h-4 w-4" />
Add Provider
</Button>
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
)}
>
<div
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="min-w-0">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
Models
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
Configure model providers and choose which ones are available.{" "}
{providers.length} available &middot; {enabledProviderCount}{" "}
enabled
</p>
</div>
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
<Button
aria-label="Search providers"
className="size-8 rounded-md"
onClick={() => setProviderSearchOpen((open) => !open)}
size="icon-sm"
type="button"
variant={providerSearchOpen ? "default" : "secondary"}
>
<Search className="size-4" />
</Button>
<Button
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
onClick={onAddProvider}
type="button"
>
<PlusCircle className="size-4" />
Add provider
</Button>
</div>
</div>
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
{providers.map((prov) => (
{providerSearchOpen ? (
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search model providers"
autoFocus
className="h-7 border-0 bg-transparent px-0 text-sm"
onChange={(event) => setProviderSearch(event.target.value)}
placeholder="Search providers"
value={providerSearch}
/>
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
{filteredProviders.length === 0 ? (
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
No providers match "{providerSearch.trim()}".
</div>
) : null}
{filteredProviders.map((prov) => (
<div
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
className={cn(
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
selectedProviderId === prov.id && "bg-accent/45",
)}
key={prov.id}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
<button
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
{prov.name}
</p>
<p className="text-xs text-muted-foreground">
<p className="shrink-0 text-[15px] text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
</p>
</div>
<Button
aria-label={`Configure ${prov.name}`}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onConfigure(prov.id)}
variant="ghost"
>
<Settings2 className="h-4 w-4" />
</Button>
</button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
<button
aria-label={`Configure ${prov.name}`}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<ChevronRight className="size-4" />
</button>
</div>
))}
</div>
@@ -158,6 +243,7 @@ export function ProviderDetailContent({
modelsError,
onOAuthLogin,
oauthLoginPending = false,
variant = "page",
}: {
provider: Provider;
onBack: () => void;
@@ -167,6 +253,7 @@ export function ProviderDetailContent({
modelsError?: string | null;
onOAuthLogin?: () => void;
oauthLoginPending?: boolean;
variant?: "page" | "panel";
}) {
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
@@ -199,6 +286,7 @@ export function ProviderDetailContent({
model.id.toLowerCase().includes(modelSearchQuery),
)
: modelList;
const isPanel = variant === "panel";
useEffect(
() => () => {
@@ -250,44 +338,65 @@ export function ProviderDetailContent({
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
)}
>
{/* Back + title */}
<div className="mb-8 flex items-center gap-3">
<Button
aria-label="Back to providers"
aria-label={
isPanel ? "Close provider details" : "Back to providers"
}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={onBack}
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
{isPanel ? (
<X className="h-4 w-4" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<h2 className="text-lg font-semibold text-foreground">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
{provider.name}
</h2>
</h1>
</div>
{configFields.length > 0 ? (
<section className="mb-8">
<div className="flex flex-col gap-5">
<section
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
>
<div className="flex flex-col">
{configFields.map((field) => {
const value = localConfigValues[field.path];
const valueText = fieldValueToString(value);
const isSecret = field.type === "password" || field.secret;
const isShown = shownSecrets[field.path] ?? false;
return (
<div key={field.path}>
<header className="mb-2">
<h3 className="text-sm font-semibold text-foreground">
<div
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
key={field.path}
>
<header>
<h3 className="text-[17px] font-semibold text-foreground">
{field.label}
</h3>
{field.description ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
{field.description}
</p>
) : null}
</header>
{field.type === "boolean" ? (
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<div className="flex items-center justify-end">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
@@ -300,7 +409,7 @@ export function ProviderDetailContent({
</div>
) : field.type === "select" ? (
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
commitField(field, event.target.value)
}
@@ -317,12 +426,12 @@ export function ProviderDetailContent({
))}
</select>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
{field.type === "url" ? (
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
<Input
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground outline-none border-0"
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
onBlur={() => commitField(field, valueText)}
onChange={(event) =>
setLocalConfigValues((current) => ({
@@ -408,10 +517,18 @@ export function ProviderDetailContent({
) : null}
{/* Models section */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<section
className={cn(
"overflow-hidden rounded-lg border",
isPanel ? "max-w-none" : "max-w-[46rem]",
)}
>
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
<h2 className="text-[17px] font-medium text-muted-foreground">
Models
</h2>
<div className="flex items-center gap-1">
<Search className="size-4 text-muted-foreground" />
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
@@ -432,7 +549,7 @@ export function ProviderDetailContent({
</div>
) : modelList.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-3 py-2">
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search models"
@@ -449,10 +566,10 @@ export function ProviderDetailContent({
/>
</div>
{filteredModelList.length > 0 ? (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
<div className="max-h-125 overflow-y-scroll border-t">
{filteredModelList.map((model) => (
<div
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
<div className="min-w-0 flex-1 font-mono">
@@ -42,7 +42,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -60,6 +59,12 @@ import {
loadProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import {
CommandBadge,
PageEmptyState,
PageFrame,
PageHeader,
} from "../page-layout";
type DateTimeValue = number | string;
@@ -871,18 +876,13 @@ export function RoutineSchedulesContent() {
);
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
Schedules
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
cline schedule
</span>
</div>
<div className="flex items-center gap-2">
<PageFrame>
<PageHeader
description="Scheduled jobs are run through the hub."
title="Schedules"
meta={<CommandBadge>cline schedule</CommandBadge>}
actions={
<>
<Button
variant="outline"
size="sm"
@@ -897,223 +897,204 @@ export function RoutineSchedulesContent() {
<Plus className="h-4 w-4" />
New Schedule
</Button>
</div>
</>
}
/>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<p className="mb-6 text-xs text-muted-foreground">
Scheduled jobs are run through the hub.
</p>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading routines...
</div>
) : sortedSchedules.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No schedules found. Create a schedule to run routines on a recurring
basis.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedSchedules.map((schedule) => {
const isBusy = busyScheduleId === schedule.scheduleId;
const activeExecution = executionBySchedule.get(
schedule.scheduleId,
);
const lastExecution = lastExecutionBySchedule.get(
schedule.scheduleId,
);
const upcoming = upcomingRuns.find(
(item) => item.scheduleId === schedule.scheduleId,
);
return (
<div
key={schedule.scheduleId}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
{isLoading ? (
<PageEmptyState>Loading schedules...</PageEmptyState>
) : sortedSchedules.length === 0 ? (
<PageEmptyState>
No schedules found. Create a schedule to run routines on a recurring
basis.
</PageEmptyState>
) : (
<div className="flex flex-col gap-3">
{sortedSchedules.map((schedule) => {
const isBusy = busyScheduleId === schedule.scheduleId;
const activeExecution = executionBySchedule.get(
schedule.scheduleId,
);
const lastExecution = lastExecutionBySchedule.get(
schedule.scheduleId,
);
const upcoming = upcomingRuns.find(
(item) => item.scheduleId === schedule.scheduleId,
);
return (
<div
key={schedule.scheduleId}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
schedule.enabled
? "fill-primary text-primary"
: "fill-muted-foreground/40 text-muted-foreground/40",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{schedule.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => {
window.alert(JSON.stringify(schedule, null, 2));
}}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Run ${schedule.name} now`}
onClick={() => void triggerSchedule(schedule.scheduleId)}
disabled={isBusy}
>
<Zap className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={
schedule.enabled
? "fill-primary text-primary"
: "fill-muted-foreground/40 text-muted-foreground/40",
? `Pause ${schedule.name}`
: `Resume ${schedule.name}`
}
onClick={() =>
void upsertScheduleEnabled(schedule, !schedule.enabled)
}
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={schedule.enabled}
onCheckedChange={(checked) =>
void upsertScheduleEnabled(schedule, checked)
}
disabled={isBusy}
aria-label={`Enable ${schedule.name}`}
/>
<h3 className="text-sm font-semibold text-foreground">
{schedule.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => {
window.alert(JSON.stringify(schedule, null, 2));
}}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Run ${schedule.name} now`}
onClick={() =>
void triggerSchedule(schedule.scheduleId)
}
disabled={isBusy}
>
<Zap className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={
schedule.enabled
? `Pause ${schedule.name}`
: `Resume ${schedule.name}`
}
onClick={() =>
void upsertScheduleEnabled(
schedule,
!schedule.enabled,
)
}
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={schedule.enabled}
onCheckedChange={(checked) =>
void upsertScheduleEnabled(schedule, checked)
}
disabled={isBusy}
aria-label={`Enable ${schedule.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
<p>
<span className="text-muted-foreground/70">ID:</span>{" "}
{schedule.scheduleId}
</p>
<p>
<span className="text-muted-foreground/70">Prompt:</span>{" "}
{schedule.prompt}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(schedule)}
</p>
{schedule.workspaceRoot && (
<p>
<span className="text-muted-foreground/70">
Workspace:
</span>{" "}
{schedule.workspaceRoot}
</p>
)}
{schedule.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{schedule.cwd}
</p>
)}
<p>
<span className="text-muted-foreground/70">
Last run:
</span>{" "}
{formatDateTime(schedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">
Last result:
</span>{" "}
{formatExecutionResult(lastExecution)}
</p>
{lastExecution?.sessionId && (
<p>
<span className="text-muted-foreground/70">
Last session:
</span>{" "}
{lastExecution.sessionId}
</p>
)}
{lastExecution?.errorMessage && (
<p className="text-destructive">
<span className="text-muted-foreground/70">
Last error:
</span>{" "}
{lastExecution.errorMessage}
</p>
)}
<p>
<span className="text-muted-foreground/70">
Next run:
</span>{" "}
{formatDateTime(
schedule.nextRunAt || upcoming?.nextRunAt,
)}
</p>
{activeExecution && (
<p>
<span className="text-muted-foreground/70">
Active:
</span>{" "}
{activeExecution.executionId} since{" "}
{formatDateTime(activeExecution.startedAt)}
</p>
)}
{schedule.tags && schedule.tags.length > 0 && (
<p>
<span className="text-muted-foreground/70">Tags:</span>{" "}
{schedule.tags.join(", ")}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
<p>
<span className="text-muted-foreground/70">ID:</span>{" "}
{schedule.scheduleId}
</p>
<p>
<span className="text-muted-foreground/70">Prompt:</span>{" "}
{schedule.prompt}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(schedule)}
</p>
{schedule.workspaceRoot && (
<p>
<span className="text-muted-foreground/70">
Workspace:
</span>{" "}
{schedule.workspaceRoot}
</p>
)}
{schedule.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{schedule.cwd}
</p>
)}
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(schedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">
Last result:
</span>{" "}
{formatExecutionResult(lastExecution)}
</p>
{lastExecution?.sessionId && (
<p>
<span className="text-muted-foreground/70">
Last session:
</span>{" "}
{lastExecution.sessionId}
</p>
)}
{lastExecution?.errorMessage && (
<p className="text-destructive">
<span className="text-muted-foreground/70">
Last error:
</span>{" "}
{lastExecution.errorMessage}
</p>
)}
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(schedule.nextRunAt || upcoming?.nextRunAt)}
</p>
{activeExecution && (
<p>
<span className="text-muted-foreground/70">Active:</span>{" "}
{activeExecution.executionId} since{" "}
{formatDateTime(activeExecution.startedAt)}
</p>
)}
{schedule.tags && schedule.tags.length > 0 && (
<p>
<span className="text-muted-foreground/70">Tags:</span>{" "}
{schedule.tags.join(", ")}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<AlertDialog
open={Boolean(schedulePendingDelete)}
onOpenChange={(open) => {
@@ -1554,6 +1535,6 @@ export function RoutineSchedulesContent() {
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollArea>
</PageFrame>
);
}
@@ -1,6 +1,4 @@
"use client";
import { ChevronDown, ChevronRight, Moon, Sun, X } from "lucide-react";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -12,11 +10,17 @@ import type {
ProviderModelsResponse,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import {
type HubTheme,
readStoredHubTheme,
readSystemHubTheme,
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { RulesView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
import {
ProviderDetailContent,
@@ -32,7 +36,6 @@ import { toSettingsPatch } from "./settings-patch";
const navCategories = [
"General",
"Providers",
"Customizations",
"MCP",
"Channels",
"Schedules",
@@ -40,7 +43,6 @@ const navCategories = [
] as const;
export type SettingsSection = (typeof navCategories)[number];
type Theme = "dark" | "light";
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
@@ -58,17 +60,15 @@ let providerCatalogCache: {
// -----------------------------------------------------------
export function SettingsView({
chrome = "full",
initialSection = "General",
onClose,
onNavigateSection,
onThemeChange,
theme,
}: {
chrome?: "full" | "content";
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
const [providersExpanded, setProvidersExpanded] = useState(true);
@@ -141,11 +141,14 @@ export function SettingsView({
}, [setProvidersWithCache]);
useEffect(() => {
if (activeNav !== "Providers") {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderCatalog();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderCatalog]);
}, [activeNav, loadProviderCatalog]);
const persistProviderSettings = useCallback(
async (
@@ -349,6 +352,86 @@ export function SettingsView({
setAddingProvider(false);
};
const providerContent = addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading providers...</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : selectedProvider ? (
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
/>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
provider={selectedProvider}
variant="panel"
/>
</aside>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
);
const content =
activeNav === "Providers" ? (
providerContent
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
);
if (chrome === "content") {
return (
<div className="h-full overflow-hidden bg-background">{content}</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
@@ -440,88 +523,17 @@ export function SettingsView({
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">
{activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={
modelsLoadingByProvider[selectedProvider.id] ?? false
}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) =>
updateProvider(selectedProvider.id, updates)
}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading providers...
</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent
onThemeChange={onThemeChange}
theme={theme}
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">{content}</div>
</div>
</div>
);
}
function GeneralSettingsContent({
onThemeChange,
theme,
}: {
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
function GeneralSettingsContent() {
const [theme, setTheme] = useState<HubTheme>(() => {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
});
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
@@ -603,85 +615,77 @@ function GeneralSettingsContent({
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
window.dispatchEvent(new Event("cline-hub-theme-change"));
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">General</h2>
<PageFrame>
<PageHeader
description="Manage Hub preferences for this browser and CLI environment."
title="Settings"
/>
<section className="max-w-[86rem]">
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Dark mode
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Keep the Hub interface in dark mode on this browser.
</p>
</div>
<Switch
aria-label="Dark mode"
checked={theme === "dark"}
onCheckedChange={updateTheme}
/>
</div>
<section className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Theme</p>
<p className="mt-1 text-xs text-muted-foreground">
Use the light or dark Cline Hub interface.
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Auto update
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Automatically install CLI updates on startup.
</p>
{autoUpdateError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update auto update setting: {autoUpdateError}
</p>
</div>
<div className="flex items-center gap-2 max-[720px]:justify-start">
<Button
onClick={() => onThemeChange("dark")}
type="button"
variant={theme === "dark" ? "default" : "outline"}
>
<Moon className="size-4" />
Dark
</Button>
<Button
onClick={() => onThemeChange("light")}
type="button"
variant={theme === "light" ? "default" : "outline"}
>
<Sun className="size-4" />
Light
</Button>
</div>
) : null}
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Auto update</p>
<p className="mt-1 text-xs text-muted-foreground">
Automatically install CLI updates on startup.
<Switch
aria-label="Auto update"
checked={autoUpdateEnabled}
disabled={autoUpdateLoading || autoUpdateSaving}
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Telemetry
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Enable error and usage report to help us improve Cline.
</p>
{telemetryError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update telemetry setting: {telemetryError}
</p>
{autoUpdateError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update auto update setting: {autoUpdateError}
</p>
) : null}
</div>
<Switch
aria-label="Auto update"
checked={autoUpdateEnabled}
disabled={autoUpdateLoading || autoUpdateSaving}
onCheckedChange={(checked) =>
void updateAutoUpdateEnabled(checked)
}
/>
) : null}
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Telemetry</p>
<p className="mt-1 text-xs text-muted-foreground">
Enable error and usage report to help us improve Cline.
</p>
{telemetryError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update telemetry setting: {telemetryError}
</p>
) : null}
</div>
<Switch
aria-label="Telemetry opt-out"
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(checked)}
/>
</div>
</section>
</div>
</ScrollArea>
<Switch
aria-label="Telemetry"
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
/>
</div>
</section>
</PageFrame>
);
}
+91 -18
View File
@@ -1,29 +1,29 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "@fontsource-variable/schibsted-grotesk";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--foreground: oklch(0.18 0.008 255);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.18 0.008 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--muted: oklch(0.974 0.003 270);
--muted-foreground: oklch(0.52 0.014 270);
--accent: oklch(0.94 0.004 270);
--accent-foreground: oklch(0.18 0.008 255);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.93 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--border: oklch(0.92 0.005 270);
--input: oklch(0.9 0.006 270);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
@@ -31,18 +31,18 @@
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.977 0.004 270);
--sidebar-foreground: oklch(0.18 0.008 255);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-accent: oklch(0.928 0.006 270);
--sidebar-accent-foreground: oklch(0.18 0.008 255);
--sidebar-border: oklch(0.92 0.005 270);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--font-sans: "Geist Variable", sans-serif;
--font-sans: "Schibsted Grotesk Variable", sans-serif;
--font-mono:
ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono",
monospace;
@@ -93,13 +93,86 @@
}
body {
@apply bg-background text-foreground;
font-optical-sizing: auto;
}
html {
@apply font-sans;
}
a[href],
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
summary,
input[type="button"]:not(:disabled),
input[type="checkbox"]:not(:disabled),
input[type="radio"]:not(:disabled),
input[type="reset"]:not(:disabled),
input[type="submit"]:not(:disabled),
label[for],
select:not(:disabled) {
cursor: pointer;
}
button:disabled,
[aria-disabled="true"],
input:disabled,
select:disabled,
textarea:disabled {
cursor: not-allowed;
}
}
:is(
[role="menuitem"],
[role="menuitemcheckbox"],
[role="menuitemradio"],
[role="option"],
[role="tab"],
[role="switch"],
[role="checkbox"],
[role="radio"],
[data-slot="switch"],
[data-slot="checkbox"],
[data-slot="radio-group-item"],
[data-slot="tabs-trigger"],
[data-slot="dropdown-menu-trigger"],
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"],
[data-slot="dropdown-menu-sub-trigger"],
[data-slot="menubar-trigger"],
[data-slot="menubar-item"],
[data-slot="menubar-checkbox-item"],
[data-slot="menubar-radio-item"],
[data-slot="menubar-sub-trigger"],
[data-slot="select-trigger"],
[data-slot="select-item"],
[data-slot="select-scroll-up-button"],
[data-slot="select-scroll-down-button"],
[data-slot="combobox-trigger"],
[data-slot="combobox-clear"],
[data-slot="combobox-item"],
[data-slot="combobox-chip-remove"],
[data-slot="command-item"],
[data-slot="accordion-trigger"],
[data-slot="collapsible-trigger"],
[data-slot="dialog-trigger"],
[data-slot="dialog-close"],
[data-slot="alert-dialog-trigger"],
[data-slot="alert-dialog-action"],
[data-slot="alert-dialog-cancel"],
[data-slot="popover-trigger"],
[data-slot="hover-card-trigger"],
[data-slot="tooltip-trigger"]
):not(:disabled):not([aria-disabled="true"]):not([data-disabled]):not(
[data-disabled="true"]
) {
cursor: pointer;
}
[data-disabled],
[data-disabled="true"] {
cursor: not-allowed;
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -110,12 +183,12 @@
}
::-webkit-scrollbar-thumb {
background: oklch(0.3 0.005 260);
background: oklch(0.82 0.006 270);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0.005 260);
background: oklch(0.72 0.01 270);
}
/* Selection color */
@@ -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),
@@ -0,0 +1,30 @@
export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
export type HubTheme = "light" | "dark";
export function readStoredHubTheme(): HubTheme | null {
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
}
export function readSystemHubTheme(): HubTheme {
const kind = document.body.dataset.vscodeThemeKind;
return kind === "vscode-dark" || kind === "vscode-high-contrast"
? "dark"
: "light";
}
export function applyHubTheme(theme: HubTheme): HubTheme {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
return theme;
}
export function syncHubTheme(): HubTheme {
return applyHubTheme(readStoredHubTheme() ?? readSystemHubTheme());
}
export function setStoredHubTheme(theme: HubTheme): HubTheme {
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
return applyHubTheme(theme);
}
+4 -12
View File
@@ -3,23 +3,15 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { TooltipProvider } from "@/components/ui/tooltip";
import App from "./App.tsx";
import { syncHubTheme } from "./lib/theme";
/**
* Sync the `dark` class on <html> with the VS Code theme.
* VS Code sets `data-vscode-theme-kind` on <body> to one of:
* "vscode-light" | "vscode-dark" | "vscode-high-contrast" | "vscode-high-contrast-light"
*/
function syncTheme() {
const kind = document.body.dataset.vscodeThemeKind;
const isDark = kind === "vscode-dark" || kind === "vscode-high-contrast";
document.documentElement.classList.toggle("dark", isDark);
}
window.addEventListener("cline-hub-theme-change", syncHubTheme);
// Apply immediately
syncTheme();
syncHubTheme();
// Re-apply whenever VS Code changes the theme attribute
const observer = new MutationObserver(syncTheme);
const observer = new MutationObserver(syncHubTheme);
observer.observe(document.body, {
attributes: true,
attributeFilter: ["data-vscode-theme-kind"],
-4
View File
@@ -1,7 +1,3 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
<div align="center">
<table>
+10
View File
@@ -127,6 +127,7 @@ message ClineRecommendedModel {
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
repeated ClineRecommendedModel cline_pass = 3;
}
// Request for fetching OpenAI models
@@ -287,6 +288,8 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 134;
optional string plan_mode_cline_model_id = 135;
optional OpenRouterModelInfo plan_mode_cline_model_info = 136;
optional string plan_mode_cline_pass_model_id = 137;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -326,6 +329,8 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 234;
optional string act_mode_cline_model_id = 235;
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
optional string act_mode_cline_pass_model_id = 237;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
}
// Request for updating API configuration (legacy - uses combined configuration)
@@ -461,6 +466,7 @@ enum ApiProvider {
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
}
enum ApiFormat {
@@ -644,6 +650,8 @@ message ModelsApiConfiguration {
optional string gemini_plan_mode_thinking_level = 139;
optional string plan_mode_cline_model_id = 140;
optional OpenRouterModelInfo plan_mode_cline_model_info = 141;
optional string plan_mode_cline_pass_model_id = 142;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -688,4 +696,6 @@ message ModelsApiConfiguration {
optional string gemini_act_mode_thinking_level = 239;
optional string act_mode_cline_model_id = 240;
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
optional string act_mode_cline_pass_model_id = 242;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
}
+34 -5
View File
@@ -1,6 +1,8 @@
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { ApiConfiguration, clinePassDefaultModelId, ModelInfo, QwenApiRegions, resolveClinePassModelInfo } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
@@ -78,7 +80,10 @@ function createHandlerForProvider(
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
): ApiHandler {
switch (apiProvider) {
const effectiveApiProvider =
apiProvider === "cline-pass" && !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS) ? "cline" : apiProvider
switch (effectiveApiProvider) {
case "anthropic":
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -258,11 +263,12 @@ function createHandlerForProvider(
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
})
case "cline": {
const configuredClineModelId = mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId
const configuredClineModelInfo = mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo
const clineModelId =
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
configuredClineModelId || (mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
const clineModelInfo =
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
configuredClineModelInfo ||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -278,6 +284,29 @@ function createHandlerForProvider(
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "cline-pass": {
const configuredClinePassModelId =
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
const configuredClinePassModelInfo =
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
? configuredClinePassModelId
: clinePassDefaultModelId
const clineModelInfo = configuredClinePassModelInfo || resolveClinePassModelInfo(clineModelId)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
clineApiKey: options.clineApiKey,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "litellm":
return new LiteLlmHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -3,6 +3,7 @@ import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
@@ -163,4 +164,34 @@ describe("ClineHandler", () => {
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
})
it("propagates a pre-stream 403 entitlement error so it classifies as Entitlement", async () => {
const handler = createHandler({})
// A 403 from ValidateModelEntitlement rejects completions.create() before streaming,
// matching the OpenAI SDK APIError shape (status + code + error body).
const apiError = Object.assign(new Error("403 the user is not subscribed to required model plan"), {
status: 403,
code: "ENTITLEMENT_ERROR",
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
})
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
let thrown: unknown
try {
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// drain
}
} catch (e) {
thrown = e
}
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
})
})
+8 -3
View File
@@ -1,4 +1,4 @@
import { type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { clinePassModels, type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
@@ -37,7 +37,10 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
const CLINE_FREE_MODEL_IDS = new Set([
...CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)),
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
])
function getCacheReadTokens(usage: any): number {
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
@@ -67,7 +70,9 @@ export class ClineHandler implements ApiHandler {
private async getFreeModelIdSet(): Promise<Set<string>> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
const freeModelIds = [...models.free, ...models.clinePass]
.map((model) => normalizeModelId(model.id))
.filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
+12 -6
View File
@@ -513,8 +513,6 @@ export class Controller {
try {
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
const clineProvider: ApiProvider = "cline"
// Get current settings to determine how to update providers
const planActSeparateModelsSetting = this.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
@@ -523,19 +521,27 @@ export class Controller {
// Get current API configuration from cache
const currentApiConfiguration = this.stateManager.getApiConfiguration()
// On login we route the user to the managed "cline" provider, but preserve a
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
// non-ClinePass logins are unaffected.
const planProvider: ApiProvider =
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const updatedConfig = { ...currentApiConfiguration }
if (planActSeparateModelsSetting) {
// Only update the current mode's provider
if (currentMode === "plan") {
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.planModeApiProvider = planProvider
} else {
updatedConfig.actModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = actProvider
}
} else {
// Update both modes to keep them in sync
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = clineProvider
updatedConfig.planModeApiProvider = planProvider
updatedConfig.actModeApiProvider = actProvider
}
// Update the API configuration through cache service
@@ -0,0 +1,60 @@
import * as assert from "assert";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import { Logger } from "@/shared/services/Logger";
import type { Controller } from "../../index";
import { clearOrganizationForClinePassProviderSelection } from "../handleClinePassProviderSelection";
describe("clearOrganizationForClinePassProviderSelection", () => {
let sandbox: sinon.SinonSandbox;
let switchAccount: sinon.SinonStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
switchAccount = sandbox.stub().resolves();
sandbox.stub(Logger, "debug");
});
afterEach(() => {
sandbox.restore();
});
function createController(): Controller {
return {
accountService: { switchAccount },
} as unknown as Controller;
}
it("does nothing when ClinePass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
});
assert.strictEqual(switchAccount.callCount, 0);
});
it("switches to the personal account when ClinePass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
});
assert.strictEqual(switchAccount.callCount, 1);
assert.strictEqual(switchAccount.firstCall.args[0], null);
});
it("logs and swallows account switch failures", async () => {
const error = new Error("not signed in");
switchAccount.rejects(error);
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "cline-pass",
});
assert.strictEqual(switchAccount.callCount, 1);
assert.strictEqual(switchAccount.firstCall.args[0], null);
assert.ok((Logger.debug as sinon.SinonStub).calledOnce);
});
});
@@ -1,27 +1,30 @@
import * as disk from "@core/storage/disk"
import axios from "axios"
import { expect } from "chai"
import fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineEnv, Environment } from "@/config"
import { Logger } from "@/shared/services/Logger"
import { refreshClineRecommendedModels, resetClineRecommendedModelsCacheForTests } from "../refreshClineRecommendedModels"
import * as disk from "@core/storage/disk";
import axios from "axios";
import { expect } from "chai";
import fs from "fs/promises";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import { ClineEnv, Environment } from "@/config";
import { Logger } from "@/shared/services/Logger";
import {
refreshClineRecommendedModels,
resetClineRecommendedModelsCacheForTests,
} from "../refreshClineRecommendedModels";
describe("refreshClineRecommendedModels", () => {
let sandbox: sinon.SinonSandbox
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox()
resetClineRecommendedModelsCacheForTests()
sandbox.stub(Logger, "log")
sandbox.stub(Logger, "error")
})
sandbox = sinon.createSandbox();
resetClineRecommendedModelsCacheForTests();
sandbox.stub(Logger, "log");
sandbox.stub(Logger, "error");
});
afterEach(() => {
resetClineRecommendedModelsCacheForTests()
sandbox.restore()
})
resetClineRecommendedModelsCacheForTests();
sandbox.restore();
});
it("fetches from upstream", async () => {
sandbox.stub(ClineEnv, "config").returns({
@@ -29,19 +32,32 @@ describe("refreshClineRecommendedModels", () => {
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "anthropic/claude-sonnet-4.6", description: "Remote recommended", tags: ["NEW"] }],
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [{ id: "z-ai/glm-5", description: "Remote free" }],
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
},
})
});
const result = await refreshClineRecommendedModels()
const result = await refreshClineRecommendedModels();
expect(axiosGetStub.calledOnce).to.equal(true)
expect(axiosGetStub.calledOnce).to.equal(true);
expect(result).to.deep.equal({
recommended: [
{
@@ -59,8 +75,16 @@ describe("refreshClineRecommendedModels", () => {
tags: [],
},
],
})
})
clinePass: [
{
id: "cline-pass/glm-5",
name: "cline-pass/glm-5",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
});
});
it("uses the in-memory cache after upstream cache is populated", async () => {
sandbox.stub(ClineEnv, "config").returns({
@@ -68,20 +92,39 @@ describe("refreshClineRecommendedModels", () => {
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "google/gemini-3.1-pro-preview", description: "Remote recommended", tags: ["NEW"] }],
free: [{ id: "minimax/minimax-m2.5", description: "Remote free", tags: ["FREE"] }],
recommended: [
{
id: "google/gemini-3.1-pro-preview",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [
{
id: "minimax/minimax-m2.5",
description: "Remote free",
tags: ["FREE"],
},
],
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
},
})
});
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
const firstResult = await refreshClineRecommendedModels();
const secondResult = await refreshClineRecommendedModels();
expect(axiosGetStub.calledOnce).to.equal(true)
expect(secondResult).to.deep.equal(firstResult)
})
})
expect(axiosGetStub.calledOnce).to.equal(true);
expect(secondResult).to.deep.equal(firstResult);
});
});
@@ -0,0 +1,29 @@
import type { ApiConfiguration } from "@shared/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* 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.
*/
export async function clearOrganizationForClinePassProviderSelection(
controller: Controller,
apiConfiguration: Pick<ApiConfiguration, "planModeApiProvider" | "actModeApiProvider">,
): Promise<void> {
if (
apiConfiguration.planModeApiProvider !== CLINE_PASS_PROVIDER_ID &&
apiConfiguration.actModeApiProvider !== CLINE_PASS_PROVIDER_ID
) {
return
}
try {
await controller.accountService.switchAccount(null)
} catch (error) {
Logger.debug("Failed to switch ClinePass to personal account", { error })
}
}
@@ -1,137 +1,192 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { ClineEnv } from "@/config"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import {
ensureCacheDirectoryExists,
GlobalFileNames,
} from "@core/storage/disk";
import axios from "axios";
import fs from "fs/promises";
import path from "path";
import { ClineEnv } from "@/config";
import { getAxiosSettings } from "@/shared/net";
import { Logger } from "@/shared/services/Logger";
export interface ClineRecommendedModelData {
id: string
name: string
description: string
tags: string[]
id: string;
name: string;
description: string;
tags: string[];
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModelData[]
free: ClineRecommendedModelData[]
recommended: ClineRecommendedModelData[];
free: ClineRecommendedModelData[];
clinePass: ClineRecommendedModelData[];
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000;
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null;
let inMemoryCache: {
data: ClineRecommendedModelsData;
timestamp: number;
} | null = null;
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
function normalizeRecommendedModel(
raw: unknown,
): ClineRecommendedModelData | null {
if (!raw || typeof raw !== "object") {
return null
return null;
}
const data = raw as Record<string, unknown>
const data = raw as Record<string, unknown>;
if (typeof data.id !== "string" || data.id.length === 0) {
return null
return null;
}
return {
id: data.id,
name: typeof data.name === "string" && data.name.length > 0 ? data.name : data.id,
name:
typeof data.name === "string" && data.name.length > 0
? data.name
: data.id,
description: typeof data.description === "string" ? data.description : "",
tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : [],
}
tags: Array.isArray(data.tags)
? data.tags.filter((tag): tag is string => typeof tag === "string")
: [],
};
}
function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModelsData | null {
function normalizeRecommendedModelsResponse(
raw: unknown,
): ClineRecommendedModelsData | null {
if (!raw || typeof raw !== "object") {
return null
return null;
}
const data = raw as Record<string, unknown>
const data = raw as Record<string, unknown>;
if (
(data.recommended !== undefined && !Array.isArray(data.recommended)) ||
(data.free !== undefined && !Array.isArray(data.free))
(data.free !== undefined && !Array.isArray(data.free)) ||
(data.clinePass !== undefined && !Array.isArray(data.clinePass))
) {
return null
return null;
}
const recommendedRaw = Array.isArray(data.recommended) ? data.recommended : []
const freeRaw = Array.isArray(data.free) ? data.free : []
const recommendedRaw = Array.isArray(data.recommended)
? data.recommended
: [];
const freeRaw = Array.isArray(data.free) ? data.free : [];
const clinePassRaw = Array.isArray(data.clinePass) ? data.clinePass : [];
const recommended = recommendedRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
.filter((model): model is ClineRecommendedModelData => model !== null);
const free = freeRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
.filter((model): model is ClineRecommendedModelData => model !== null);
return { recommended, free }
const clinePass = clinePassRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null);
return { recommended, free, clinePass };
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
return inMemoryCache.data
if (
inMemoryCache &&
Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS
) {
return inMemoryCache.data;
}
if (pendingRefresh) {
return pendingRefresh
return pendingRefresh;
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheClineRecommendedModels()
return await fetchAndCacheClineRecommendedModels();
} finally {
pendingRefresh = null
pendingRefresh = null;
}
})()
})();
return pendingRefresh
return pendingRefresh;
}
export function resetClineRecommendedModelsCacheForTests(): void {
pendingRefresh = null
inMemoryCache = null
pendingRefresh = null;
inMemoryCache = null;
}
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
const clineRecommendedModelsFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.clineRecommendedModels,
);
let result: ClineRecommendedModelsData = {
recommended: [],
free: [],
clinePass: [],
};
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/recommended-models`, getAxiosSettings())
const normalized = normalizeRecommendedModelsResponse(response.data)
const apiBaseUrl = ClineEnv.config().apiBaseUrl;
const response = await axios.get(
`${apiBaseUrl}/api/v1/ai/cline/recommended-models`,
getAxiosSettings(),
);
const normalized = normalizeRecommendedModelsResponse(response.data);
if (!normalized) {
throw new Error("Invalid response data when fetching Cline recommended models")
throw new Error(
"Invalid response data when fetching Cline recommended models",
);
}
result = normalized
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result))
Logger.log("Cline recommended models fetched and saved")
result = normalized;
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result));
Logger.log("Cline recommended models fetched and saved");
} catch (error) {
Logger.error("Error fetching Cline recommended models:", error)
Logger.error("Error fetching Cline recommended models:", error);
try {
const fileExists = await fs
.access(clineRecommendedModelsFilePath)
.then(() => true)
.catch(() => false)
.catch(() => false);
if (fileExists) {
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
const parsed = JSON.parse(fileContents)
const fileContents = await fs.readFile(
clineRecommendedModelsFilePath,
"utf8",
);
const parsed = JSON.parse(fileContents);
if (parsed) {
result = parsed
Logger.log("Loaded Cline recommended models from cache")
result = {
recommended: Array.isArray(parsed.recommended)
? parsed.recommended
: [],
free: Array.isArray(parsed.free) ? parsed.free : [],
clinePass: Array.isArray(parsed.clinePass) ? parsed.clinePass : [],
};
Logger.log("Loaded Cline recommended models from cache");
}
}
} catch (cacheError) {
Logger.error("Error reading Cline recommended models from cache:", cacheError)
Logger.error(
"Error reading Cline recommended models from cache:",
cacheError,
);
}
}
// Avoid pinning empty results in memory for the full TTL after a transient API/cache miss.
if (result.recommended.length > 0 || result.free.length > 0) {
inMemoryCache = { data: result, timestamp: Date.now() }
if (
result.recommended.length > 0 ||
result.free.length > 0 ||
result.clinePass.length > 0
) {
inMemoryCache = { data: result, timestamp: Date.now() };
}
return result
return result;
}
@@ -25,5 +25,13 @@ export async function refreshClineRecommendedModelsRpc(
tags: model.tags,
}),
),
clinePass: models.clinePass.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
})
}
@@ -6,6 +6,7 @@ import { UpdateApiConfigurationRequestNew } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { Secrets } from "@/shared/storage/state-keys"
import type { Controller } from "../index"
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection"
/**
* Parses field mask paths into separate sets for options and secrets
@@ -41,7 +42,8 @@ function parseFieldMask(updateMask: string[]): {
function getAlternateModeField(fieldName: string): string | null {
if (fieldName.startsWith("planMode")) {
return fieldName.replace("planMode", "actMode")
} else if (fieldName.startsWith("actMode")) {
}
if (fieldName.startsWith("actMode")) {
return fieldName.replace("actMode", "planMode")
}
return null
@@ -136,6 +138,7 @@ export async function updateApiConfiguration(controller: Controller, request: Up
}
if (Object.keys(options).length > 0) {
controller.stateManager.setGlobalStateBatch(options)
await clearOrganizationForClinePassProviderSelection(controller, controller.stateManager.getApiConfiguration())
}
// Update the task's API handler if there's an active task
@@ -4,6 +4,7 @@ import { UpdateApiConfigurationPartialRequest } from "@shared/proto/cline/models
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection"
/**
* Updates API configuration with partial values using FieldMask
@@ -42,6 +43,7 @@ export async function updateApiConfigurationPartial(
// Update storage and task API handler
controller.stateManager.setApiConfiguration(updatedConfig)
await clearOrganizationForClinePassProviderSelection(controller, updatedConfig)
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
controller.task.api = buildApiHandler({ ...updatedConfig, ulid: controller.task.ulid }, currentMode)
@@ -1,16 +1,17 @@
import { Empty } from "@shared/proto/cline/common"
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Empty } from "@shared/proto/cline/common";
import type { UpdateApiConfigurationRequest } from "@shared/proto/cline/models";
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion";
import {
fromProtobufLiteLLMModelInfo,
fromProtobufModelInfo,
fromProtobufOcaModelInfo,
fromProtobufOpenAiCompatibleModelInfo,
} from "@shared/proto-conversions/models/typeConversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { buildApiHandler } from "@/core/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
} from "@shared/proto-conversions/models/typeConversion";
import type { OpenaiReasoningEffort } from "@shared/storage/types";
import { buildApiHandler } from "@/core/api";
import { Logger } from "@/shared/services/Logger";
import type { Controller } from "../index";
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection";
/**
* Updates API configuration
@@ -24,18 +25,22 @@ export async function updateApiConfigurationProto(
): Promise<Empty> {
try {
if (!request.apiConfiguration) {
Logger.log("[APICONFIG: updateApiConfigurationProto] API configuration is required")
throw new Error("API configuration is required")
Logger.log(
"[APICONFIG: updateApiConfigurationProto] API configuration is required",
);
throw new Error("API configuration is required");
}
const protoApiConfiguration = request.apiConfiguration
const protoApiConfiguration = request.apiConfiguration;
const convertedApiConfigurationFromProto = {
...protoApiConfiguration,
// Convert proto ApiProvider enums to native string types
planModeApiProvider:
protoApiConfiguration.planModeApiProvider !== undefined
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider!)
? convertProtoToApiProvider(
protoApiConfiguration.planModeApiProvider!,
)
: undefined,
actModeApiProvider:
protoApiConfiguration.actModeApiProvider !== undefined
@@ -44,20 +49,36 @@ export async function updateApiConfigurationProto(
// Convert ModelInfo objects (empty arrays → undefined)
// Plan Mode
planModeOpenRouterModelInfo: protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeOpenRouterModelInfo)
: undefined,
planModeOpenRouterModelInfo:
protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeOpenRouterModelInfo,
)
: undefined,
planModeClineModelInfo: protoApiConfiguration.planModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeClineModelInfo)
: undefined,
planModeClinePassModelInfo:
protoApiConfiguration.planModeClinePassModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeClinePassModelInfo,
)
: undefined,
planModeOpenAiModelInfo: protoApiConfiguration.planModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeOpenAiModelInfo)
: undefined,
planModeHuggingFaceModelInfo: protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuggingFaceModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.planModeOpenAiModelInfo,
)
: undefined,
planModeHuggingFaceModelInfo:
protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeHuggingFaceModelInfo,
)
: undefined,
planModeLiteLlmModelInfo: protoApiConfiguration.planModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.planModeLiteLlmModelInfo)
? fromProtobufLiteLLMModelInfo(
protoApiConfiguration.planModeLiteLlmModelInfo,
)
: undefined,
planModeRequestyModelInfo: protoApiConfiguration.planModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeRequestyModelInfo)
@@ -65,34 +86,52 @@ export async function updateApiConfigurationProto(
planModeGroqModelInfo: protoApiConfiguration.planModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeGroqModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo: protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuaweiCloudMaasModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo:
protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeHuaweiCloudMaasModelInfo,
)
: undefined,
planModeBasetenModelInfo: protoApiConfiguration.planModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeBasetenModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo: protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeVercelAiGatewayModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo:
protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeVercelAiGatewayModelInfo,
)
: undefined,
planModeOcaModelInfo: protoApiConfiguration.planModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.planModeOcaModelInfo)
: undefined,
planModeAihubmixModelInfo: protoApiConfiguration.planModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeAihubmixModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.planModeAihubmixModelInfo,
)
: undefined,
// Act Mode
actModeOpenRouterModelInfo: protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeOpenRouterModelInfo)
: undefined,
actModeOpenRouterModelInfo:
protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeOpenRouterModelInfo,
)
: undefined,
actModeClineModelInfo: protoApiConfiguration.actModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClineModelInfo)
: undefined,
actModeClinePassModelInfo: protoApiConfiguration.actModeClinePassModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClinePassModelInfo)
: undefined,
actModeOpenAiModelInfo: protoApiConfiguration.actModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeOpenAiModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.actModeOpenAiModelInfo,
)
: undefined,
actModeLiteLlmModelInfo: protoApiConfiguration.actModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.actModeLiteLlmModelInfo)
? fromProtobufLiteLLMModelInfo(
protoApiConfiguration.actModeLiteLlmModelInfo,
)
: undefined,
actModeRequestyModelInfo: protoApiConfiguration.actModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeRequestyModelInfo)
@@ -100,48 +139,71 @@ export async function updateApiConfigurationProto(
actModeGroqModelInfo: protoApiConfiguration.actModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeGroqModelInfo)
: undefined,
actModeHuggingFaceModelInfo: protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuggingFaceModelInfo)
: undefined,
actModeHuaweiCloudMaasModelInfo: protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuaweiCloudMaasModelInfo)
: undefined,
actModeHuggingFaceModelInfo:
protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeHuggingFaceModelInfo,
)
: undefined,
actModeHuaweiCloudMaasModelInfo:
protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeHuaweiCloudMaasModelInfo,
)
: undefined,
actModeBasetenModelInfo: protoApiConfiguration.actModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeBasetenModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo: protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeVercelAiGatewayModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo:
protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeVercelAiGatewayModelInfo,
)
: undefined,
actModeOcaModelInfo: protoApiConfiguration.actModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.actModeOcaModelInfo)
: undefined,
actModeAihubmixModelInfo: protoApiConfiguration.actModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeAihubmixModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.actModeAihubmixModelInfo,
)
: undefined,
geminiPlanModeThinkingLevel: protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel: protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
geminiPlanModeThinkingLevel:
protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel:
protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as
| OpenaiReasoningEffort
| undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as
| OpenaiReasoningEffort
| undefined,
};
// Update the API configuration in storage
controller.stateManager.setApiConfiguration(convertedApiConfigurationFromProto)
controller.stateManager.setApiConfiguration(
convertedApiConfigurationFromProto,
);
await clearOrganizationForClinePassProviderSelection(
controller,
convertedApiConfigurationFromProto,
);
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode");
controller.task.api = buildApiHandler(
{ ...convertedApiConfigurationFromProto, ulid: controller.task.ulid },
currentMode,
)
);
}
// Post updated state to webview
await controller.postStateToWebview()
await controller.postStateToWebview();
return Empty.create()
return Empty.create();
} catch (error) {
Logger.error(`Failed to update API configuration: ${error}`)
throw error
Logger.error(`Failed to update API configuration: ${error}`);
throw error;
}
}
@@ -1,176 +1,246 @@
import * as diskStorage from "@core/storage/disk"
import * as remoteConfigFetch from "@core/storage/remote-config/fetch"
import * as remoteConfigUtils from "@core/storage/remote-config/utils"
import * as assert from "assert"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import * as diskStorage from "@core/storage/disk";
import * as remoteConfigFetch from "@core/storage/remote-config/fetch";
import * as remoteConfigUtils from "@core/storage/remote-config/utils";
import * as assert from "assert";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import type { Controller } from "@/core/controller";
import { ClineAccountService } from "@/services/account/ClineAccountService";
import { AuthService } from "@/services/auth/AuthService";
describe("fetchRemoteConfig", () => {
let sandbox: sinon.SinonSandbox
let accountService: ClineAccountService
let authServiceStub: Partial<AuthService>
let fetchUserRemoteConfigStub: sinon.SinonStub
let isRemoteConfigEnabledStub: sinon.SinonStub
let sandbox: sinon.SinonSandbox;
let accountService: ClineAccountService;
let authServiceStub: Partial<AuthService>;
let fetchUserRemoteConfigStub: sinon.SinonStub;
let isRemoteConfigEnabledStub: sinon.SinonStub;
function createController(overrides: Record<string, unknown> = {}) {
return {
accountService: { switchAccount: sandbox.stub().resolves() },
stateManager: {
getApiConfiguration: sandbox.stub().returns({
planModeApiProvider: "cline",
actModeApiProvider: "cline",
}),
getGlobalSettingsKey: sandbox.stub().withArgs("mode").returns("act"),
setSecret: sandbox.stub(),
},
mcpHub: {},
postStateToWebview: sandbox.stub(),
...overrides,
};
}
beforeEach(() => {
sandbox = sinon.createSandbox()
authServiceStub = {}
sandbox.stub(AuthService, "getInstance").returns(authServiceStub as AuthService)
accountService = new ClineAccountService()
sandbox.stub(ClineAccountService, "getInstance").returns(accountService)
fetchUserRemoteConfigStub = sandbox.stub(accountService, "fetchUserRemoteConfig")
isRemoteConfigEnabledStub = sandbox.stub(remoteConfigUtils, "isRemoteConfigEnabled").returns(true)
sandbox.stub(remoteConfigUtils, "applyRemoteConfig").resolves()
sandbox.stub(remoteConfigUtils, "clearRemoteConfig")
sandbox.stub(diskStorage, "writeRemoteConfigToCache").resolves()
sandbox.stub(diskStorage, "readRemoteConfigFromCache").resolves({ version: "v1" })
sandbox.stub(diskStorage, "deleteRemoteConfigFromCache").resolves()
})
sandbox = sinon.createSandbox();
authServiceStub = {};
sandbox
.stub(AuthService, "getInstance")
.returns(authServiceStub as AuthService);
accountService = new ClineAccountService();
sandbox.stub(ClineAccountService, "getInstance").returns(accountService);
fetchUserRemoteConfigStub = sandbox.stub(
accountService,
"fetchUserRemoteConfig",
);
isRemoteConfigEnabledStub = sandbox
.stub(remoteConfigUtils, "isRemoteConfigEnabled")
.returns(true);
sandbox.stub(remoteConfigUtils, "applyRemoteConfig").resolves();
sandbox.stub(remoteConfigUtils, "clearRemoteConfig");
sandbox.stub(diskStorage, "writeRemoteConfigToCache").resolves();
sandbox
.stub(diskStorage, "readRemoteConfigFromCache")
.resolves({ version: "v1" });
sandbox.stub(diskStorage, "deleteRemoteConfigFromCache").resolves();
});
afterEach(() => {
sandbox.restore()
})
sandbox.restore();
});
it("clears remote config and skips discovery when ClinePass is selected", async () => {
const controller = createController({
stateManager: {
getApiConfiguration: sandbox.stub().returns({
planModeApiProvider: "cline",
actModeApiProvider: "cline-pass",
}),
getGlobalSettingsKey: sandbox.stub().withArgs("mode").returns("act"),
setSecret: sandbox.stub(),
},
});
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok(
(remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).calledOnce,
);
assert.strictEqual(fetchUserRemoteConfigStub.callCount, 0);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
assert.strictEqual(controller.postStateToWebview.callCount, 1);
});
it("switches org when not in the chosen org", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-current",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub().resolves() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.strictEqual(controller.accountService.switchAccount.callCount, 1)
assert.strictEqual(controller.accountService.switchAccount.firstCall.args[0], "org-target")
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.strictEqual(controller.accountService.switchAccount.callCount, 1);
assert.strictEqual(
controller.accountService.switchAccount.firstCall.args[0],
"org-target",
);
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("skips switchAccount when already in the chosen org", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("uses discoveredValue inline and skips org-level config fetch", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
getAuthToken: () => Promise.resolve("token"),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
// writeRemoteConfigToCache is called with the parsed config, proving inline parse succeeded.
// If it had fallen through to fetchRemoteConfigForOrganization, it would need getAuthToken
// and make an HTTP call — but no axios stub is set up, so the test would fail.
assert.ok((diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce)
})
assert.ok(
(diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce,
);
});
it("falls back to org-level fetch when discoveredValue fails to parse", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
getAuthToken: () => Promise.resolve(null),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: "not valid json{{{",
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// Parse failed → fetchRemoteConfigForOrganization → no auth → cache fallback
assert.ok((diskStorage.readRemoteConfigFromCache as sinon.SinonStub).called)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.ok(
(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).called,
);
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("does not switch org when resolve fails", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-current",
getAuthToken: () => Promise.resolve(null),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: "not valid json{{{",
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
// Both inline parse and org-level fetch fail (no auth → no fetch), cache is empty
;(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves(undefined)
(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves(
undefined,
);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// Config resolution failed — user should stay in their current org
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("falls back to next locally-allowed org when backend org is opted-out", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-3",
getAuthToken: () => Promise.resolve("token"),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-1",
@@ -180,26 +250,29 @@ describe("fetchRemoteConfig", () => {
{ organizationId: "org-2", name: "Org 2" },
{ organizationId: "org-3", name: "Org 3" },
],
})
isRemoteConfigEnabledStub.reset()
isRemoteConfigEnabledStub.withArgs("org-1").returns(false)
isRemoteConfigEnabledStub.withArgs("org-2").returns(false)
isRemoteConfigEnabledStub.withArgs("org-3").returns(true)
});
isRemoteConfigEnabledStub.reset();
isRemoteConfigEnabledStub.withArgs("org-1").returns(false);
isRemoteConfigEnabledStub.withArgs("org-2").returns(false);
isRemoteConfigEnabledStub.withArgs("org-3").returns(true);
// Fallback org has no discoveredValue, so it will go through fetchRemoteConfigForOrganization
// which needs auth → will fall back to cache
;(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves({ version: "v1" })
(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves({
version: "v1",
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub().resolves() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("clears remote config when all orgs are locally opted-out", async () => {
fetchUserRemoteConfigStub.resolves({
@@ -209,109 +282,126 @@ describe("fetchRemoteConfig", () => {
{ organizationId: "org-1", name: "Org 1" },
{ organizationId: "org-2", name: "Org 2" },
],
})
isRemoteConfigEnabledStub.reset()
isRemoteConfigEnabledStub.returns(false)
});
isRemoteConfigEnabledStub.reset();
isRemoteConfigEnabledStub.returns(false);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("calls clearRemoteConfig when discovery returns no qualifying org", async () => {
fetchUserRemoteConfigStub.resolves(undefined)
fetchUserRemoteConfigStub.resolves(undefined);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("clears remote config when isRemoteConfigEnabled toggled off mid-flight", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
isRemoteConfigEnabledStub.reset()
isRemoteConfigEnabledStub.onFirstCall().returns(true)
isRemoteConfigEnabledStub.onSecondCall().returns(false)
isRemoteConfigEnabledStub.reset();
isRemoteConfigEnabledStub.onFirstCall().returns(true);
isRemoteConfigEnabledStub.onSecondCall().returns(false);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.ok(
(diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce,
);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("preserves existing config on unexpected network error", async () => {
fetchUserRemoteConfigStub.rejects(new Error("network failure"))
fetchUserRemoteConfigStub.rejects(new Error("network failure"));
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// Transient errors should NOT clear existing remote config
assert.strictEqual((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual(controller.postStateToWebview.callCount, 0)
})
assert.strictEqual(
(remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount,
0,
);
assert.strictEqual(controller.postStateToWebview.callCount, 0);
});
it("preserves existing config when switchAccount rejects", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-current",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
accountService: { switchAccount: sandbox.stub().rejects(new Error("switch failed")) },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
const controller = createController({
accountService: {
switchAccount: sandbox.stub().rejects(new Error("switch failed")),
},
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// switchAccount failure should NOT clear existing remote config
assert.strictEqual((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
})
assert.strictEqual(
(remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount,
0,
);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
});
@@ -1,5 +1,6 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { Controller } from "@/core/controller"
import { CLINE_PASS_PROVIDER_ID } from "@/core/controller/models/handleClinePassProviderSelection"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { buildBasicClineHeaders } from "@/services/EnvUtils"
import { getAxiosSettings } from "@/shared/net"
@@ -157,9 +158,7 @@ async function fetchApiKeysForOrganization(organizationId: string): Promise<APIK
}
}
async function discoverRemoteConfigOrg(): Promise<
{ organizationId: string; discoveredValue?: string } | undefined
> {
async function discoverRemoteConfigOrg(): Promise<{ organizationId: string; discoveredValue?: string } | undefined> {
const accountService = ClineAccountService.getInstance()
const discovery = await accountService.fetchUserRemoteConfig()
@@ -194,10 +193,7 @@ function parseDiscoveredConfig(value: string, organizationId: string): RemoteCon
}
}
async function resolveRemoteConfig(
organizationId: string,
discoveredValue?: string,
): Promise<RemoteConfig | undefined> {
async function resolveRemoteConfig(organizationId: string, discoveredValue?: string): Promise<RemoteConfig | undefined> {
if (discoveredValue) {
const config = parseDiscoveredConfig(discoveredValue, organizationId)
if (config) {
@@ -207,6 +203,15 @@ async function resolveRemoteConfig(
return fetchRemoteConfigForOrganization(organizationId)
}
function isClinePassSelected(controller: Controller): boolean {
const apiConfiguration = controller.stateManager.getApiConfiguration()
return (
apiConfiguration.planModeApiProvider === CLINE_PASS_PROVIDER_ID ||
apiConfiguration.actModeApiProvider === CLINE_PASS_PROVIDER_ID
)
}
/**
* Discovers the target org, resolves its remote config, switches org if needed,
* fetches API keys, and applies the config. Clears remote config when no
@@ -217,6 +222,13 @@ async function resolveRemoteConfig(
*/
async function ensureUserInOrgWithRemoteConfig(controller: Controller): Promise<RemoteConfig | undefined> {
const authService = AuthService.getInstance()
if (isClinePassSelected(controller)) {
clearRemoteConfig()
controller.postStateToWebview()
return undefined
}
const discovered = await discoverRemoteConfigOrg()
if (!discovered) {
+13 -2
View File
@@ -87,6 +87,7 @@ import {
} from "@shared/Languages";
import { USER_CONTENT_TAGS } from "@shared/messages/constants";
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message";
import { FeatureFlag } from "@shared/services/feature-flags/feature-flags";
import { type ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools";
import type { ClineAskResponse } from "@shared/WebviewMessage";
import {
@@ -2033,11 +2034,16 @@ export class Task {
const model = this.api.getModel();
const apiConfig = this.stateManager.getApiConfiguration();
const mode = this.stateManager.getGlobalSettingsKey("mode");
const providerId = (
const configuredProviderId = (
mode === "plan"
? apiConfig.planModeApiProvider
: apiConfig.actModeApiProvider
) as string;
const providerId =
configuredProviderId === "cline-pass" &&
!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS)
? "cline"
: configuredProviderId;
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt");
return { model, providerId, customPrompt, mode };
}
@@ -2460,6 +2466,9 @@ export class Task {
const quotaExceeded = clineError.isErrorType(
ClineErrorType.QuotaExceeded,
);
const isEntitlementError = clineError.isErrorType(
ClineErrorType.Entitlement,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2485,6 +2494,7 @@ export class Task {
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
this.taskState.autoRetryAttempts < 3;
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
@@ -2546,7 +2556,8 @@ export class Task {
!isClineProviderInsufficientCredits &&
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded;
!quotaExceeded &&
!isEntitlementError;
if (showRetry) {
await this.say(
"error_retry",
@@ -733,8 +733,9 @@ export class SubagentRunner {
const parsedError = ClineError.transform(error, modelId, providerId)
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
const isEntitlementError = parsedError.isErrorType(ClineErrorType.Entitlement)
if (isAuthError || isBalanceError) {
if (isAuthError || isBalanceError || isEntitlementError) {
return false
}
@@ -462,6 +462,38 @@ describe("SubagentRunner", () => {
assert.match(result.error || "", /stream_initialization_failed/i)
})
it("does not retry initial ClinePass entitlement errors", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield* []
throw Object.assign(new Error("403 the user is not subscribed to required model plan"), {
status: 403,
code: "ENTITLEMENT_ERROR",
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
})
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const runner = new SubagentRunner(createTaskConfig(false))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "failed")
assert.equal(createMessage.callCount, 1)
assert.match(result.error || "", /not subscribed to required model plan/i)
})
it("fails context window errors", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
@@ -285,7 +285,7 @@ export class ClineAccountService {
* @returns {Promise<void>} A promise that resolves when the account switch is complete.
* @throws {Error} If the account switch fails, an error will be thrown.
*/
async switchAccount(organizationId?: string): Promise<void> {
async switchAccount(organizationId?: string | null): Promise<void> {
// Call API to switch account
try {
// make XHR request to switch account
@@ -8,6 +8,7 @@ export enum ClineErrorType {
Balance = "balance",
SpendLimit = "spendLimit",
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
}
interface ErrorDetails {
@@ -152,6 +153,14 @@ export class ClineError extends Error {
return ClineErrorType.SpendLimit
}
// Scoped to the individual "not subscribed" case; other ENTITLEMENT_ERROR variants (e.g. org
// accounts) fall through. Checked before the generic auth check since these are returned as 403.
const isEntitlementCode = code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR"
const entitlementText = `${message ?? ""} ${details?.message ?? ""}`.toLowerCase()
if (isEntitlementCode && entitlementText.includes("not subscribed to required model plan")) {
return ClineErrorType.Entitlement
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
@@ -8,5 +8,62 @@ describe("ClineError", () => {
const err = new ClineError({ message: "Inference cap reached", code: "INFERENCE_CAP_ERROR" })
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
})
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
// status 403 would otherwise be classified as Auth; the entitlement code must win.
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
const err = new ClineError(
{
status: 403,
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should NOT classify the organization ENTITLEMENT_ERROR variant as Entitlement", () => {
// Org accounts can't use individual subs; this case is intentionally out of scope and
// falls through to generic handling rather than showing the ClinePass card.
const err = new ClineError({
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
code: "ENTITLEMENT_ERROR",
status: 403,
})
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.Entitlement).should.be.true()
})
})
})
+77 -1
View File
@@ -22,6 +22,7 @@ export type ApiProvider =
| "mistral"
| "vscode-lm"
| "cline"
| "cline-pass"
| "litellm"
| "moonshot"
| "nebius"
@@ -1023,6 +1024,58 @@ export const clineDevstralModelInfo: ModelInfo = {
description: "A stealth model for agentic coding tasks",
}
export type ClinePassModelId = keyof typeof clinePassModels
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
export const clinePassModelInfoSaneDefaults: ModelInfo = {
maxTokens: 8_192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
supportsReasoning: true,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
description: "",
}
export const clinePassModels = {
"cline-pass/glm-5.1": {
name: "cline-pass/glm-5.1",
maxTokens: 131_072,
contextWindow: 202_752,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.98,
outputPrice: 3.08,
cacheReadsPrice: 0.182,
cacheWritesPrice: 0,
description: "",
},
} as const satisfies Record<string, ModelInfo>
export function getModelSlug(modelId: string): string {
return modelId.split("/").at(-1) ?? modelId
}
export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record<string, ModelInfo> {
const nameMap: Record<string, ModelInfo> = {}
for (const [id, info] of Object.entries(models)) {
nameMap[getModelSlug(id)] = info
}
return nameMap
}
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
return (
clinePassModels[modelId as keyof typeof clinePassModels] ??
modelInfoByName?.[getModelSlug(modelId)] ??
clinePassModelInfoSaneDefaults
)
}
export const OPENROUTER_PROVIDER_PREFERENCES: Record<string, { order: string[]; allow_fallbacks: boolean }> = {
// Exacto Providers
"moonshotai/kimi-k2:exacto": {
@@ -5143,6 +5196,18 @@ export const fireworksModels = {
description:
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p6-fast": {
maxTokens: 262000,
contextWindow: 262000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.3,
description:
"Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
maxTokens: 262000,
contextWindow: 262000,
@@ -5163,7 +5228,7 @@ export const fireworksModels = {
inputPrice: 0.14,
outputPrice: 0.28,
cacheWritesPrice: 0,
cacheReadsPrice: 0.03,
cacheReadsPrice: 0.028,
description:
"DeepSeek V4 Flash is a fast, cost-efficient reasoning model with a 1M context window and strong tool-use capabilities.",
},
@@ -5179,6 +5244,17 @@ export const fireworksModels = {
description:
"DeepSeek V4 Pro is a flagship reasoning model with a 1M context window, advanced structured output, and agentic performance.",
},
"accounts/fireworks/models/glm-5p2": {
maxTokens: 131072,
contextWindow: 1048576,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.4,
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
description: "GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
},
"accounts/fireworks/models/glm-5p1": {
maxTokens: 131072,
contextWindow: 202800,
@@ -280,6 +280,8 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.VSCODE_LM
case "cline":
return ProtoApiProvider.CLINE
case "cline-pass":
return ProtoApiProvider.CLINE_PASS
case "litellm":
return ProtoApiProvider.LITELLM
case "moonshot":
@@ -372,6 +374,8 @@ export function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvid
return "vscode-lm"
case ProtoApiProvider.CLINE:
return "cline"
case ProtoApiProvider.CLINE_PASS:
return "cline-pass"
case ProtoApiProvider.LITELLM:
return "litellm"
case ProtoApiProvider.MOONSHOT:
@@ -528,6 +532,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
planModeClineModelId: config.planModeClineModelId,
planModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClineModelInfo),
planModeClinePassModelId: config.planModeClinePassModelId,
planModeClinePassModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClinePassModelInfo),
planModeOpenAiModelId: config.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.planModeOpenAiModelInfo),
planModeOllamaModelId: config.planModeOllamaModelId,
@@ -572,6 +578,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
actModeClineModelId: config.actModeClineModelId,
actModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClineModelInfo),
actModeClinePassModelId: config.actModeClinePassModelId,
actModeClinePassModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClinePassModelInfo),
actModeOpenAiModelId: config.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.actModeOpenAiModelInfo),
actModeOllamaModelId: config.actModeOllamaModelId,
@@ -711,6 +719,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
planModeClineModelId: protoConfig.planModeClineModelId,
planModeClineModelInfo: convertProtoToModelInfo(protoConfig.planModeClineModelInfo),
planModeClinePassModelId: protoConfig.planModeClinePassModelId,
planModeClinePassModelInfo: convertProtoToModelInfo(protoConfig.planModeClinePassModelInfo),
planModeOpenAiModelId: protoConfig.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.planModeOpenAiModelInfo),
planModeOllamaModelId: protoConfig.planModeOllamaModelId,
@@ -756,6 +766,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
actModeClineModelId: protoConfig.actModeClineModelId,
actModeClineModelInfo: convertProtoToModelInfo(protoConfig.actModeClineModelInfo),
actModeClinePassModelId: protoConfig.actModeClinePassModelId,
actModeClinePassModelInfo: convertProtoToModelInfo(protoConfig.actModeClinePassModelInfo),
actModeOpenAiModelId: protoConfig.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.actModeOpenAiModelInfo),
actModeOllamaModelId: protoConfig.actModeOllamaModelId,
@@ -4,6 +4,10 @@
"value": "cline",
"label": "Cline"
},
{
"value": "cline-pass",
"label": "ClinePass"
},
{
"value": "openai-codex",
"label": "ChatGPT Subscription"
@@ -15,6 +15,8 @@ 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 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",
}
@@ -27,6 +29,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.EXTENSION_REMOTE_BANNERS_TTL]: 24 * 60 * 60 * 1000,
[FeatureFlag.REMOTE_WELCOME_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
[FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT]: false,
[FeatureFlag.CLINE_PASS]: false,
[FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE]: false,
}

Some files were not shown because too many files have changed in this diff Show More