mirror of
https://github.com/cline/cline.git
synced 2026-09-16 21:01:52 +08:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091eccdfe2 | ||
|
|
a2a46ae600 | ||
|
|
82c9e77de2 | ||
|
|
dfd0e022a4 | ||
|
|
f0ec6a35bb | ||
|
|
c09d54f5a2 | ||
|
|
984d70a351 | ||
|
|
bbe7b6fd49 | ||
|
|
be97d951fa | ||
|
|
c331a8f4b6 | ||
|
|
f180f1584d | ||
|
|
9197d15abf | ||
|
|
6c0d5c97b1 | ||
|
|
9a8be88e85 | ||
|
|
60f4a482ca | ||
|
|
dbe15202e1 | ||
|
|
8d102db392 | ||
|
|
3dfd5dc31c | ||
|
|
43ce9f3694 | ||
|
|
f1c73fb48b | ||
|
|
abaa8383c4 | ||
|
|
3a5e372d73 | ||
|
|
cf3a59f0e2 | ||
|
|
b3aee68ca5 | ||
|
|
cd8fd29063 | ||
|
|
7777d61311 | ||
|
|
64fc3f372e | ||
|
|
4175677e71 | ||
|
|
4934450947 | ||
|
|
b0a2d8a223 | ||
|
|
d9f1d862a5 | ||
|
|
f8d73f3811 | ||
|
|
9aac8340dc | ||
|
|
242b5ebff6 | ||
|
|
7ca41fdb7d | ||
|
|
000918989f | ||
|
|
9560b6d625 | ||
|
|
c7304097a7 | ||
|
|
674a6022ee | ||
|
|
dbf0775384 | ||
|
|
e130b45eb0 | ||
|
|
6f4dbae86f | ||
|
|
c7de31ae24 | ||
|
|
45dddb9a4e | ||
|
|
e32caee96b | ||
|
|
8f6dae0ac0 |
@@ -0,0 +1,294 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,5 +1,26 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.34
|
||||
|
||||
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
|
||||
- Improved the wording of the ClinePass onboarding step.
|
||||
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
|
||||
|
||||
## 3.0.33
|
||||
|
||||
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
|
||||
- Hide the ClinePass promo for users who already have a ClinePass subscription
|
||||
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
|
||||
|
||||
## 3.0.32
|
||||
|
||||
- Improved the ClinePass onboarding experience
|
||||
- Added an intermediate step before going to ClinePass model selection
|
||||
- Made the ClinePass subscription screen selectable
|
||||
- Promoted ClinePass in the startup notice
|
||||
- Used "ClinePass" as one word consistently and refined the provider UI copy
|
||||
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
|
||||
|
||||
## 3.0.31
|
||||
|
||||
- Show when request cost is covered by your Cline subscription
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.31",
|
||||
"version": "3.0.34",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -313,6 +313,45 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "run_commands",
|
||||
input: {
|
||||
commands: [{ command: "cmd", args: ["/c", "dir"] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
const code = await runHistoryExport("sess_1", outputPath, "text", io);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(io.writeErr).not.toHaveBeenCalled();
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
|
||||
});
|
||||
|
||||
it("fails when the session artifact is missing", async () => {
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
|
||||
const io = {
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: false,
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { resolveCliSessionMetadata } from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import {
|
||||
parseLocalRowMetadata,
|
||||
@@ -64,8 +63,7 @@ export async function buildConnectorStartRequest(input: {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
@@ -10,10 +13,29 @@ export function MigrationNoticeContent(
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
const openSubscriptionPage = useCallback(() => {
|
||||
setStatus("Opening ClinePass in your browser...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened ClinePass in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus(
|
||||
"Could not open the browser automatically. Use the URL below.",
|
||||
);
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
@@ -22,25 +44,24 @@ export function MigrationNoticeContent(
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
We rebuilt the CLI from the ground up using the new Cline SDK. Learn
|
||||
more:{" "}
|
||||
<a href="https://github.com/cline/cline">
|
||||
<span fg={palette.act}>https://github.com/cline/cline</span>
|
||||
</a>
|
||||
ClinePass is a $9.99/month subscription plan to get access to the
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>
|
||||
Running{" "}
|
||||
<span fg="#98c379" bg="#1f2937">
|
||||
{" cline "}
|
||||
</span>{" "}
|
||||
now opens the terminal UI. To open Kanban, use /quit and run{" "}
|
||||
<span fg="#98c379" bg="#1f2937">
|
||||
{" cline kanban "}
|
||||
</span>{" "}
|
||||
in your terminal
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={palette.muted}>Press Esc to close</text>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Open ClinePass</text>
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClineCliMigrationNotice,
|
||||
markClineCliMigrationNoticeShown,
|
||||
resolveCliNoticeStatePath,
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider,
|
||||
} from "./notice";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -26,8 +33,25 @@ describe("migration notice", () => {
|
||||
it("returns the notice for a fresh data dir", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
|
||||
"Welcome to the new Cline CLI",
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
|
||||
});
|
||||
|
||||
it("shows when only the old Kanban notice was marked as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
noticePath,
|
||||
`${JSON.stringify(
|
||||
{ shown: { "cline-cli-tui-default": true } },
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
|
||||
"cline-cli-cline-pass-intro",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +70,7 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -56,18 +80,56 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not show when ClinePass is already the active provider", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(
|
||||
dataDir,
|
||||
{},
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
|
||||
expect(
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(" cline-pass "),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress the active ClinePass provider when forced", () => {
|
||||
expect(
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider("cline-pass", {
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows for the active ClinePass provider when forced", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(
|
||||
dataDir,
|
||||
{ CLINE_FORCE_CLINE_PASS_NOTICE: "1" },
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows when forced even if disabled through the environment", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -78,7 +140,7 @@ describe("migration notice", () => {
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain("cline-cli-tui-default");
|
||||
expect(rawState).toContain("cline-cli-cline-pass-intro");
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
const NOTICE_ID = "cline-cli-tui-default";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
|
||||
const NOTICE_ID = "cline-cli-cline-pass-intro";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
|
||||
|
||||
export interface CliMigrationNotice {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CliMigrationNoticeOptions {
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
interface CliNoticeState {
|
||||
shown: Record<string, boolean>;
|
||||
}
|
||||
@@ -49,6 +53,19 @@ function readNoticeState(filePath: string): CliNoticeState {
|
||||
return { shown };
|
||||
}
|
||||
|
||||
function isForceNoticeEnabled(env: NodeJS.ProcessEnv): boolean {
|
||||
return env[FORCE_NOTICE_ENV]?.trim() === "1";
|
||||
}
|
||||
|
||||
export function shouldSuppressClineCliMigrationNoticeForActiveProvider(
|
||||
activeProviderId: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
activeProviderId?.trim() === "cline-pass" && !isForceNoticeEnabled(env)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCliNoticeStatePath(
|
||||
dataDir = resolveClineDataDir(),
|
||||
): string {
|
||||
@@ -58,20 +75,29 @@ export function resolveCliNoticeStatePath(
|
||||
export function getClineCliMigrationNotice(
|
||||
dataDir = resolveClineDataDir(),
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: CliMigrationNoticeOptions = {},
|
||||
): CliMigrationNotice | undefined {
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
const noticeState = readNoticeState(noticePath);
|
||||
const forceNotice = env[FORCE_NOTICE_ENV]?.trim() === "1";
|
||||
const forceNotice = isForceNoticeEnabled(env);
|
||||
const disableNotice = env[DISABLE_NOTICE_ENV]?.trim() === "1";
|
||||
if (disableNotice && !forceNotice) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(
|
||||
options.activeProviderId,
|
||||
env,
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: NOTICE_ID,
|
||||
title: "Welcome to the new Cline CLI",
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+60
-10
@@ -1,6 +1,9 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CliMigrationNotice } from "./kanban-migration/notice";
|
||||
import type {
|
||||
CliMigrationNotice,
|
||||
CliMigrationNoticeOptions,
|
||||
} from "./kanban-migration/notice";
|
||||
|
||||
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
|
||||
const fsActual = vi.hoisted(() => ({
|
||||
@@ -59,9 +62,13 @@ const dashboardMocks = vi.hoisted(() => ({
|
||||
runDashboardCommand: vi.fn(),
|
||||
}));
|
||||
const migrationNoticeMocks = vi.hoisted(() => ({
|
||||
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
|
||||
() => undefined,
|
||||
),
|
||||
getClineCliMigrationNotice: vi.fn<
|
||||
(
|
||||
dataDir?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
options?: CliMigrationNoticeOptions,
|
||||
) => CliMigrationNotice | undefined
|
||||
>(() => undefined),
|
||||
markClineCliMigrationNoticeShown: vi.fn(),
|
||||
}));
|
||||
const updateMocks = vi.hoisted(() => ({
|
||||
@@ -115,6 +122,7 @@ const telemetryMocks = vi.hoisted(() => ({
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
setCliFeatureFlagsAccountContext: vi.fn(),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
@@ -179,7 +187,8 @@ vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
refreshCliFeatureFlagsInBackground:
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext:
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
}));
|
||||
@@ -258,6 +267,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReset();
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground.mockReset();
|
||||
kanbanMocks.launchKanban.mockReset();
|
||||
kanbanMocks.launchKanban.mockResolvedValue(0);
|
||||
dashboardMocks.runDashboardCommand.mockReset();
|
||||
@@ -630,8 +640,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("passes the migration notice marker into interactive mode", async () => {
|
||||
const notice = {
|
||||
id: "cline-cli-tui-default",
|
||||
title: "Welcome to the new Cline CLI",
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
@@ -662,6 +672,37 @@ describe("runCli lightweight command dispatch", () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the active ClinePass provider into the migration notice gate", async () => {
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/test-model",
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(
|
||||
migrationNoticeMocks.getClineCliMigrationNotice,
|
||||
).toHaveBeenCalledWith(undefined, process.env, {
|
||||
activeProviderId: "cline-pass",
|
||||
});
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialNotice: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start OAuth before onboarding in interactive mode", async () => {
|
||||
authMocks.isOAuthProvider.mockReturnValue(true);
|
||||
authMocks.normalizeProviderId.mockReturnValue("cline");
|
||||
@@ -942,7 +983,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
|
||||
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
@@ -961,11 +1002,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
).toHaveBeenCalledWith({ id: "acct-startup" });
|
||||
// The account identity must be seeded before flags are refreshed/used so
|
||||
// the background refresh resolves flags for the correct account.
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
|
||||
.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground.mock
|
||||
.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1297,7 +1341,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "say hello"];
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"basic",
|
||||
"say hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext,
|
||||
} from "./utils/feature-flags";
|
||||
@@ -956,8 +955,7 @@ export async function runCli(): Promise<void> {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
@@ -1182,7 +1180,9 @@ export async function runCli(): Promise<void> {
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice();
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
activeProviderId: provider,
|
||||
});
|
||||
if (initialNotice) {
|
||||
markInitialNoticeShown = () => {
|
||||
markClineCliMigrationNoticeShown();
|
||||
|
||||
@@ -365,6 +365,48 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
new SessionNotFoundError("session-1"),
|
||||
);
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: [],
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
|
||||
const manager = makeManager();
|
||||
let runtime!: ReturnType<typeof makeRuntime>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "stale" }],
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
|
||||
@@ -49,6 +49,13 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type CurrentMessagesRead =
|
||||
| { messages: Message[]; status: "read" }
|
||||
| { messages: Message[]; status: "recovered" }
|
||||
| { messages: Message[]; status: "stale" };
|
||||
type MissingSessionRecovery = {
|
||||
messages: Message[];
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
) => NonNullable<Config["toolPolicies"]>[string];
|
||||
@@ -103,7 +110,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
let missingSessionRecoveryPromise: Promise<void> | undefined;
|
||||
let missingSessionRecoveryPromise:
|
||||
| Promise<MissingSessionRecovery>
|
||||
| undefined;
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
@@ -275,14 +284,34 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
const readCurrentMessages = async (): Promise<Message[]> => {
|
||||
if (!sessionManager || !activeSessionId) {
|
||||
return [];
|
||||
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
return { messages: [], status: "read" };
|
||||
}
|
||||
try {
|
||||
const messages = (await manager.readMessages(sessionId)) ?? [];
|
||||
return {
|
||||
messages,
|
||||
status: activeSessionId === sessionId ? "read" : "stale",
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
abortRequested ||
|
||||
shutdownRequested ||
|
||||
!isSessionNotFoundError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const recovery = await recoverMissingActiveSession(error);
|
||||
return { messages: recovery.messages, status: "recovered" };
|
||||
}
|
||||
return (await sessionManager.readMessages(activeSessionId)) ?? [];
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
|
||||
const recoverMissingActiveSession = async (
|
||||
error: unknown,
|
||||
): Promise<MissingSessionRecovery> => {
|
||||
if (missingSessionRecoveryPromise) {
|
||||
return await missingSessionRecoveryPromise;
|
||||
}
|
||||
@@ -290,7 +319,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const manager = sessionManager;
|
||||
const missingSessionId = activeSessionId;
|
||||
if (!manager || !missingSessionId || shutdownRequested) {
|
||||
return;
|
||||
return { messages: [] };
|
||||
}
|
||||
const messages = await manager
|
||||
.readMessages(missingSessionId)
|
||||
@@ -307,6 +336,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
startupError = undefined;
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages);
|
||||
return { messages };
|
||||
})().finally(() => {
|
||||
missingSessionRecoveryPromise = undefined;
|
||||
});
|
||||
@@ -361,7 +391,13 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const messages = await readCurrentMessages();
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status !== "read") {
|
||||
// If reading recovered a missing hub session, the current messages are
|
||||
// already in the replacement session. If the read is stale, another async
|
||||
// operation changed the active session while this read was in flight.
|
||||
return;
|
||||
}
|
||||
await restartWithMessages(messages);
|
||||
};
|
||||
|
||||
@@ -510,7 +546,13 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (!sessionManager) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const messages = await readCurrentMessages();
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status === "stale" || (status === "recovered" && !activeSessionId)) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
// If reading messages recovered the session, `messages` are the same messages
|
||||
// used to seed the replacement session, so it is safe to compact the current
|
||||
// active session with them.
|
||||
const messagesBefore = messages.length;
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
@@ -551,7 +593,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return undefined;
|
||||
}
|
||||
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
|
||||
const messages = await readCurrentMessages();
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status !== "read") {
|
||||
return undefined;
|
||||
}
|
||||
return { messages, checkpointHistory };
|
||||
};
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ export async function runInteractive(
|
||||
? async () => {
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
const messages = await sessionRuntime.readCurrentMessages();
|
||||
const { messages } = await sessionRuntime.readCurrentMessages();
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ToolResultContent,
|
||||
type ToolUseContent,
|
||||
} from "@cline/shared";
|
||||
import { formatStructuredCommand } from "../utils/helpers";
|
||||
|
||||
export interface ConversationHistory {
|
||||
version: number;
|
||||
@@ -845,15 +846,15 @@ function renderDiffHTML(
|
||||
}
|
||||
|
||||
function renderCommandsHTML(
|
||||
commands: string[],
|
||||
commands: unknown[],
|
||||
_result?: ToolResultContent,
|
||||
): string {
|
||||
return commands
|
||||
.map(
|
||||
(cmd, i) => `
|
||||
(command, i) => `
|
||||
<div class="command-block">
|
||||
<div class="command-label">Command ${i + 1}</div>
|
||||
<code>${escapeHtml(cmd)}</code>
|
||||
<code>${escapeHtml(formatStructuredCommand(command))}</code>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -124,7 +124,7 @@ export function clineEnv(
|
||||
}),
|
||||
CLINE_SESSION_DATA_DIR: path.join(dataDir, "sessions"),
|
||||
CLINE_TEAM_DATA_DIR: path.join(dataDir, "teams"),
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
NO_UPDATE_NOTIFIER: "1",
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
...extra,
|
||||
|
||||
@@ -13,6 +13,7 @@ const coreMocks = vi.hoisted(() => {
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
fetchCurrentUserPlan: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
@@ -45,6 +46,9 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
fetchCurrentUserPlan() {
|
||||
return coreMocks.fetchCurrentUserPlan();
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -107,6 +111,7 @@ describe("createClineAccountService", () => {
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -204,6 +209,7 @@ describe("loadClineAccountSnapshot", () => {
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -268,6 +274,7 @@ describe("loadIndividualSubscriptionPlans", () => {
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
@@ -125,8 +126,10 @@ export async function createClineAccountService(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const manager =
|
||||
input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
@@ -216,6 +219,48 @@ export async function loadIndividualSubscriptionPlans(input: {
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlan(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlanFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
|
||||
@@ -6,10 +6,10 @@ import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
@@ -37,12 +38,6 @@ import {
|
||||
} from "../utils/tool-parsing";
|
||||
import { ToolOutput } from "./tool-output";
|
||||
|
||||
function getIndividualPlanFeatures(plans: ClineSubscriptionPlan[]): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function trimLeading(text: string): string {
|
||||
return text.replace(/^\n+/, "");
|
||||
}
|
||||
@@ -274,14 +269,8 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE =
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue.";
|
||||
const OUT_OF_CREDITS_MESSAGE =
|
||||
"You have run out of Cline credits. Add credits in the dashboard to continue.";
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -297,30 +286,46 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={
|
||||
isClinePassEnabled
|
||||
? CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE
|
||||
: OUT_OF_CREDITS_MESSAGE
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
|
||||
}
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Dashboard: </text>
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase ClinePass: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Switch to ClinePass: </text>
|
||||
<text fg="gray">
|
||||
type /settings in CLI and switch provider to ClinePass
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadIndividualSubscriptionPlans) {
|
||||
@@ -345,15 +350,15 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<text fg={planAccent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
borderColor={planAccent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">ClinePass subscription required</text>
|
||||
<text fg={planAccent}>ClinePass subscription required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -363,12 +368,10 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={props.defaultFg}>ClinePass includes:</text>
|
||||
{planFeatures.map((feature) => (
|
||||
<box key={feature} flexDirection="row">
|
||||
<text fg="green" content="✓ " />
|
||||
<text fg={props.defaultFg} selectable>
|
||||
{feature}
|
||||
</text>
|
||||
</box>
|
||||
<text key={feature} fg={props.defaultFg} selectable>
|
||||
<span fg="green">✓ </span>
|
||||
<span>{feature}</span>
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
@@ -391,18 +394,21 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<text fg={planAccent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
borderColor={planAccent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text fg={planAccent}>Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -516,6 +522,7 @@ export function ChatEntryView(props: {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -526,6 +533,7 @@ export function ChatEntryView(props: {
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
const url = new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -251,7 +252,8 @@ export function ProviderPickerContent(
|
||||
export type ExistingProviderAction =
|
||||
| "use_existing"
|
||||
| "reconfigure"
|
||||
| "open_subscription";
|
||||
| "open_subscription_page"
|
||||
| "open_usage_billing";
|
||||
|
||||
export interface ExistingProviderOption {
|
||||
value: ExistingProviderAction;
|
||||
@@ -259,18 +261,6 @@ export interface ExistingProviderOption {
|
||||
onSelect?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
return new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function UseExistingOrReconfigureContent(
|
||||
props: ChoiceContext<ExistingProviderOption> & {
|
||||
providerName: string;
|
||||
@@ -340,28 +330,34 @@ export function UseExistingOrReconfigureContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinePassSubscriptionContent(
|
||||
function ClinePassBrowserPageContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerName: string;
|
||||
pageLabel: string;
|
||||
url: string;
|
||||
openedStatus: string;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const subscriptionUrl = useMemo(
|
||||
() =>
|
||||
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerName,
|
||||
pageLabel,
|
||||
url,
|
||||
openedStatus,
|
||||
} = props;
|
||||
const [status, setStatus] = useState("Opening browser...");
|
||||
|
||||
useEffect(() => {
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
void open(url, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened subscription page in your browser.");
|
||||
setStatus(openedStatus);
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
}, [url, openedStatus]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -381,9 +377,9 @@ export function ClinePassSubscriptionContent(
|
||||
|
||||
<text>{status}</text>
|
||||
|
||||
<text fg="gray">Subscription page:</text>
|
||||
<text fg="gray">{pageLabel}:</text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
<a href={url}>{url}</a>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
@@ -393,6 +389,27 @@ export function ClinePassSubscriptionContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinePassSubscriptionContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const subscriptionUrl = useMemo(
|
||||
() =>
|
||||
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClinePassBrowserPageContent
|
||||
{...props}
|
||||
pageLabel="Subscription page"
|
||||
url={subscriptionUrl}
|
||||
openedStatus="Opened subscription page in your browser."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createContextBar,
|
||||
formatStatusBarUsageText,
|
||||
resolveContextBarFilledForeground,
|
||||
resolveModelDisplayName,
|
||||
} from "./status-bar";
|
||||
|
||||
vi.mock("@opentui/react", () => ({
|
||||
@@ -67,6 +68,32 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
).toBe("(12,345 tokens) $0.00 (included with your subscription)");
|
||||
).toBe("(12,345 tokens) $0.00 (included with subscription)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelDisplayName", () => {
|
||||
it("keeps ClinePass visible when model ids have provider prefixes", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
}),
|
||||
).toBe("ClinePass/glm-5.2");
|
||||
});
|
||||
|
||||
it("uses the friendly model name for non-ClinePass providers", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
}),
|
||||
).toBe("GLM 5.2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ function formatCost(cost: number): string {
|
||||
|
||||
function formatCostText(providerId: string, totalCost: number): string {
|
||||
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
|
||||
return "$0.00 (included with your subscription)";
|
||||
return "$0.00 (included with subscription)";
|
||||
}
|
||||
|
||||
if (!shouldShowCliUsageCost(providerId)) {
|
||||
@@ -94,17 +94,22 @@ function lookupModelInfo(
|
||||
}
|
||||
|
||||
export function resolveModelDisplayName(config: {
|
||||
providerId?: string;
|
||||
modelId: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: string;
|
||||
}): string {
|
||||
const info = lookupModelInfo(config.modelId, config.knownModels);
|
||||
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
|
||||
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
|
||||
const displayName =
|
||||
config.providerId === "cline-pass"
|
||||
? `ClinePass/${modelIdTail}`
|
||||
: (info?.name ?? modelIdTail);
|
||||
if (config.thinking && config.reasoningEffort) {
|
||||
return `${name} (${config.reasoningEffort})`;
|
||||
return `${displayName} (${config.reasoningEffort})`;
|
||||
}
|
||||
return name;
|
||||
return displayName;
|
||||
}
|
||||
|
||||
export function resolveModelMaxInputTokens(config: {
|
||||
|
||||
@@ -91,8 +91,8 @@ function providerToExistingProviderOptions(input: {
|
||||
|
||||
return [
|
||||
{
|
||||
value: "open_subscription",
|
||||
label: "Open ClinePass subscription page",
|
||||
value: "open_subscription_page",
|
||||
label: "Manage subscription & see usage",
|
||||
onSelect: async () => {
|
||||
await input.dialog.choice<boolean>({
|
||||
style: { maxHeight: input.termHeight - 2 },
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useDialogState,
|
||||
} from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
|
||||
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
@@ -541,10 +542,17 @@ function App(props: TuiProps) {
|
||||
|
||||
const notice = props.initialNotice;
|
||||
const onInitialNoticeShown = props.onInitialNoticeShown;
|
||||
const currentProviderId = props.config.providerId;
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
if (initialNoticeShownRef.current) return;
|
||||
if (appView !== "home") return;
|
||||
if (
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
|
||||
) {
|
||||
initialNoticeShownRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
initialNoticeShownRef.current = true;
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -560,7 +568,7 @@ function App(props: TuiProps) {
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [appView, dialog, notice, onInitialNoticeShown]);
|
||||
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
|
||||
|
||||
const {
|
||||
appendEntry: appendSessionEntry,
|
||||
|
||||
@@ -10,16 +10,24 @@ import {
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import open from "open";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
} from "../../../utils/cline-pass-errors";
|
||||
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";
|
||||
import {
|
||||
loadCurrentUserPlanFromProviderSettings,
|
||||
loadIndividualSubscriptionPlansFromProviderSettings,
|
||||
} from "../../cline-account";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -48,6 +56,8 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
@@ -78,8 +88,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
getMainMenuOptions({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -150,6 +159,19 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [modelsDefaultId, setModelsDefaultId] = useState("");
|
||||
const [customModelId, setCustomModelId] = useState("");
|
||||
const [customModelError, setCustomModelError] = useState("");
|
||||
const [clinePassSubscriptionStatus, setClinePassSubscriptionStatus] =
|
||||
useState<ClinePassSubscriptionStatus>("loading");
|
||||
const [clinePassSubscriptionError, setClinePassSubscriptionError] =
|
||||
useState("");
|
||||
const [clinePassCurrentPlanName, setClinePassCurrentPlanName] = useState("");
|
||||
const [clinePassPlanFeatures, setClinePassPlanFeatures] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [clinePassSubscriptionSelected, setClinePassSubscriptionSelected] =
|
||||
useState(0);
|
||||
const [clinePassSubscriptionOpenStatus, setClinePassSubscriptionOpenStatus] =
|
||||
useState("");
|
||||
const clinePassSubscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
|
||||
const modelItems: SearchableItem[] = useMemo(
|
||||
() =>
|
||||
@@ -265,6 +287,62 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
[providerSettingsManager],
|
||||
);
|
||||
|
||||
const refreshClinePassSubscriptionStatus = useCallback(() => {
|
||||
setClinePassSubscriptionStatus("loading");
|
||||
setClinePassSubscriptionError("");
|
||||
setClinePassCurrentPlanName("");
|
||||
setClinePassSubscriptionOpenStatus("");
|
||||
|
||||
loadCurrentUserPlanFromProviderSettings({ providerSettingsManager })
|
||||
.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
.then((currentPlanResult) =>
|
||||
loadIndividualSubscriptionPlansFromProviderSettings({
|
||||
providerSettingsManager,
|
||||
})
|
||||
.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
.then((availablePlansResult) => ({
|
||||
availablePlansResult,
|
||||
currentPlanResult,
|
||||
})),
|
||||
)
|
||||
.then(({ currentPlanResult, availablePlansResult }) => {
|
||||
if (availablePlansResult.status === "fulfilled") {
|
||||
setClinePassPlanFeatures(
|
||||
getIndividualPlanFeatures(availablePlansResult.value),
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPlanResult.status === "rejected") {
|
||||
const error = currentPlanResult.reason;
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (message.trim().toLowerCase() === "no plan found for user") {
|
||||
setClinePassSubscriptionStatus("unsubscribed");
|
||||
return;
|
||||
}
|
||||
setClinePassSubscriptionError(message);
|
||||
setClinePassSubscriptionStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = currentPlanResult.value?.plan;
|
||||
if (plan) {
|
||||
setClinePassCurrentPlanName(
|
||||
plan.displayName || plan.name || plan.id || "ClinePass",
|
||||
);
|
||||
setClinePassSubscriptionStatus("subscribed");
|
||||
} else {
|
||||
setClinePassSubscriptionStatus("unsubscribed");
|
||||
}
|
||||
});
|
||||
}, [providerSettingsManager]);
|
||||
|
||||
const transitionToModelPicker = useCallback(
|
||||
(providerId: string) => {
|
||||
setActiveProviderId(providerId);
|
||||
@@ -288,6 +366,27 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
[providers, loadModelsForProvider, providerSettingsManager],
|
||||
);
|
||||
|
||||
const transitionToClinePassSubscription = useCallback(() => {
|
||||
setActiveProviderId("cline-pass");
|
||||
const provider = providers.find((p) => p.id === "cline-pass");
|
||||
setActiveProviderName(provider?.name ?? "ClinePass");
|
||||
setModelsDefaultId(provider?.defaultModelId ?? "");
|
||||
setClinePassSubscriptionSelected(0);
|
||||
setStep("cline_pass_subscription");
|
||||
refreshClinePassSubscriptionStatus();
|
||||
}, [providers, refreshClinePassSubscriptionStatus]);
|
||||
|
||||
const handleAuthComplete = useCallback(
|
||||
(providerId: OnboardingOAuthProviderId) => {
|
||||
if (providerId === "cline-pass") {
|
||||
transitionToClinePassSubscription();
|
||||
return;
|
||||
}
|
||||
transitionToModelPicker(providerId);
|
||||
},
|
||||
[transitionToClinePassSubscription, transitionToModelPicker],
|
||||
);
|
||||
|
||||
const resetAuth = useCallback(() => {
|
||||
setAuthStatus("");
|
||||
setAuthUrl("");
|
||||
@@ -313,11 +412,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setVerifyUrl: setDeviceVerifyUrl,
|
||||
setStatus: setDeviceStatus,
|
||||
setError: setDeviceError,
|
||||
onComplete: transitionToModelPicker,
|
||||
onComplete: handleAuthComplete,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[providerSettingsManager, transitionToModelPicker],
|
||||
[providerSettingsManager, handleAuthComplete],
|
||||
);
|
||||
|
||||
const startOAuthFlow = useCallback(
|
||||
@@ -339,18 +438,46 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setStatus: setAuthStatus,
|
||||
setAuthUrl,
|
||||
setError: setAuthError,
|
||||
onComplete: transitionToModelPicker,
|
||||
onComplete: handleAuthComplete,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[
|
||||
providerSettingsManager,
|
||||
resetAuth,
|
||||
transitionToModelPicker,
|
||||
handleAuthComplete,
|
||||
startDeviceCodeFlow,
|
||||
],
|
||||
);
|
||||
|
||||
const continueFromClinePassSubscription = useCallback(() => {
|
||||
transitionToModelPicker("cline-pass");
|
||||
}, [transitionToModelPicker]);
|
||||
|
||||
const openClinePassSubscriptionPage = useCallback(() => {
|
||||
setClinePassSubscriptionOpenStatus("Opening subscription page...");
|
||||
void open(clinePassSubscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setClinePassSubscriptionOpenStatus(
|
||||
"Opened subscription page in your browser.",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setClinePassSubscriptionOpenStatus(
|
||||
`Could not open browser automatically. Open ${clinePassSubscriptionUrl}`,
|
||||
);
|
||||
});
|
||||
}, [clinePassSubscriptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
step === "cline_pass_subscription" &&
|
||||
clinePassSubscriptionStatus === "subscribed"
|
||||
) {
|
||||
transitionToModelPicker("cline-pass");
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
@@ -632,6 +759,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
modelList,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
thinkingSelected,
|
||||
setStep,
|
||||
setMenuSelected,
|
||||
@@ -648,7 +778,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setDeviceError,
|
||||
setDeviceStatus,
|
||||
setClineModelSelected,
|
||||
setClinePassSubscriptionSelected,
|
||||
setThinkingSelected,
|
||||
continueFromClinePassSubscription,
|
||||
refreshClinePassSubscriptionStatus,
|
||||
openClinePassSubscriptionPage,
|
||||
abortOAuth: () => {
|
||||
authAbortRef.current = true;
|
||||
},
|
||||
@@ -670,6 +804,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
return {
|
||||
activeProviderName,
|
||||
activeProviderId,
|
||||
authError,
|
||||
authStatus,
|
||||
authUrl,
|
||||
@@ -682,6 +817,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
clinePassPlanFeatures,
|
||||
clinePassSubscriptionError,
|
||||
clinePassSubscriptionOpenStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionUrl,
|
||||
deviceError,
|
||||
deviceStatus,
|
||||
deviceUserCode,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
type OnboardingStep,
|
||||
THINKING_LEVELS,
|
||||
@@ -26,6 +28,9 @@ export function useOnboardingKeyboard(input: {
|
||||
modelList: SearchableListState;
|
||||
clineEntries: ClineModelPickerEntry[];
|
||||
clineModelSelected: number;
|
||||
clinePassSubscriptionStatus: ClinePassSubscriptionStatus;
|
||||
clinePassSubscriptionOptions: ClinePassSubscriptionOption[];
|
||||
clinePassSubscriptionSelected: number;
|
||||
thinkingSelected: number;
|
||||
setStep: (step: OnboardingStep) => void;
|
||||
setMenuSelected: Dispatch<SetStateAction<number>>;
|
||||
@@ -38,7 +43,11 @@ export function useOnboardingKeyboard(input: {
|
||||
setDeviceError: (value: string) => void;
|
||||
setDeviceStatus: (value: string) => void;
|
||||
setClineModelSelected: Dispatch<SetStateAction<number>>;
|
||||
setClinePassSubscriptionSelected: Dispatch<SetStateAction<number>>;
|
||||
setThinkingSelected: Dispatch<SetStateAction<number>>;
|
||||
continueFromClinePassSubscription: () => void;
|
||||
refreshClinePassSubscriptionStatus: () => void;
|
||||
openClinePassSubscriptionPage: () => void;
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
@@ -93,6 +102,11 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
if (input.step === "cline_pass_subscription") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "cline_model") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
@@ -135,6 +149,43 @@ export function useOnboardingKeyboard(input: {
|
||||
|
||||
if (input.step === "device_code") return;
|
||||
|
||||
if (input.step === "cline_pass_subscription") {
|
||||
const total = input.clinePassSubscriptionOptions.length;
|
||||
if (total === 0) return;
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
input.setClinePassSubscriptionSelected((s) =>
|
||||
s <= 0 ? total - 1 : s - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
||||
input.setClinePassSubscriptionSelected((s) =>
|
||||
s >= total - 1 ? 0 : s + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const option =
|
||||
input.clinePassSubscriptionOptions[
|
||||
Math.min(input.clinePassSubscriptionSelected, total - 1)
|
||||
];
|
||||
if (!option) return;
|
||||
if (option.value === "subscribe") {
|
||||
input.openClinePassSubscriptionPage();
|
||||
} else if (option.value === "refresh") {
|
||||
if (input.clinePassSubscriptionStatus !== "loading") {
|
||||
input.refreshClinePassSubscriptionStatus();
|
||||
}
|
||||
} else if (option.value === "skip") {
|
||||
input.continueFromClinePassSubscription();
|
||||
} else if (option.value === "back") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "menu") {
|
||||
if (key.name === "up") {
|
||||
input.setMenuSelected((s) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type OnboardingStep =
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
| "custom_model_id"
|
||||
@@ -36,6 +37,17 @@ export interface MenuOption {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionAction =
|
||||
| "subscribe"
|
||||
| "refresh"
|
||||
| "skip"
|
||||
| "back";
|
||||
|
||||
export interface ClinePassSubscriptionOption {
|
||||
value: ClinePassSubscriptionAction;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const MAIN_MENU: MenuOption[] = [
|
||||
{
|
||||
label: "Sign in with Cline",
|
||||
@@ -71,6 +83,25 @@ export function getMainMenuOptions(options?: {
|
||||
);
|
||||
}
|
||||
|
||||
export const CLINE_PASS_SUBSCRIPTION_OPTIONS: ClinePassSubscriptionOption[] = [
|
||||
{
|
||||
value: "subscribe",
|
||||
label: "Subscribe to ClinePass",
|
||||
},
|
||||
{
|
||||
value: "refresh",
|
||||
label: "Re-check subscription status",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip for now",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Go back",
|
||||
},
|
||||
];
|
||||
|
||||
export interface OnboardingResult {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -96,6 +127,12 @@ export interface ModelEntry {
|
||||
supportsReasoning: boolean;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionStatus =
|
||||
| "loading"
|
||||
| "subscribed"
|
||||
| "unsubscribed"
|
||||
| "error";
|
||||
|
||||
export interface ProviderCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "opentui-spinner/react";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
@@ -17,10 +19,18 @@ import {
|
||||
TrackedRobot,
|
||||
type useMouseTracker,
|
||||
} from "../../components/tracked-robot";
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { type MenuOption, THINKING_LEVELS } from "./model";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
@@ -29,6 +39,10 @@ function useDefaultFg(): string | undefined {
|
||||
return getDefaultForeground(terminalBg);
|
||||
}
|
||||
|
||||
function getClinePassSubscriptionOptionId(index: number): string {
|
||||
return `cline-pass-subscription-option-${index}`;
|
||||
}
|
||||
|
||||
interface OnboardingFrameProps {
|
||||
children: ReactNode;
|
||||
compact: boolean;
|
||||
@@ -468,6 +482,198 @@ export function OnboardingClineModelScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
currentPlanName: string;
|
||||
error: string;
|
||||
mouse: MouseTrackerState;
|
||||
openStatus: string;
|
||||
options: ClinePassSubscriptionOption[];
|
||||
planFeatures: string[];
|
||||
selected: number;
|
||||
status: ClinePassSubscriptionStatus;
|
||||
subscriptionUrl: string;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const planAccent = getModeAccent("plan", terminalTheme);
|
||||
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const isLoading = props.status === "loading";
|
||||
const isSubscribed = props.status === "subscribed";
|
||||
const isError = props.status === "error";
|
||||
const bodyHeight = props.compact ? 17 : 19;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubscribed) {
|
||||
return;
|
||||
}
|
||||
const scrollSelectedOptionIntoView = () => {
|
||||
scrollRef.current?.scrollChildIntoView(
|
||||
getClinePassSubscriptionOptionId(props.selected),
|
||||
);
|
||||
};
|
||||
scrollSelectedOptionIntoView();
|
||||
queueMicrotask(scrollSelectedOptionIntoView);
|
||||
const timeout = setTimeout(scrollSelectedOptionIntoView, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSubscribed, props.selected]);
|
||||
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
contentWidth={props.contentWidth}
|
||||
mouse={props.mouse}
|
||||
>
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={isSubscribed ? palette.success : planAccent}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
height={bodyHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
<scrollbox
|
||||
ref={scrollRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
scrollY
|
||||
scrollX={false}
|
||||
viewportOptions={{ overflow: "hidden" }}
|
||||
contentOptions={{ flexDirection: "column" }}
|
||||
>
|
||||
<box flexDirection="column" width="100%" flexShrink={0}>
|
||||
<text
|
||||
fg={isSubscribed ? palette.success : planAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSubscribed
|
||||
? "ClinePass subscription active"
|
||||
: "ClinePass subscription required"}
|
||||
</text>
|
||||
|
||||
{isLoading ? (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">Checking your ClinePass subscription...</text>
|
||||
</box>
|
||||
) : isSubscribed ? (
|
||||
<text fg={defaultFg} selectable flexShrink={0}>
|
||||
Current plan: {props.currentPlanName || "ClinePass"}
|
||||
</text>
|
||||
) : isError ? (
|
||||
<text
|
||||
fg={defaultFg}
|
||||
selectable
|
||||
flexShrink={0}
|
||||
content="Could not verify your ClinePass subscription. Re-check before choosing a ClinePass model."
|
||||
/>
|
||||
) : (
|
||||
<text
|
||||
fg={defaultFg}
|
||||
selectable
|
||||
flexShrink={0}
|
||||
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.status === "error" &&
|
||||
props.error &&
|
||||
props.error !== "no plan found for user" && (
|
||||
<text fg="red" selectable flexShrink={0}>
|
||||
{props.error}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{!isSubscribed && props.planFeatures.length > 0 && (
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
{props.planFeatures.map((feature) => {
|
||||
if (
|
||||
feature === "Low cost subscription pricing" ||
|
||||
feature === "Generous limits and reliable access" ||
|
||||
feature === "Built for as many programmers as possible"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
key={feature}
|
||||
fg={defaultFg}
|
||||
selectable
|
||||
flexShrink={0}
|
||||
>
|
||||
<span fg="green">✓ </span>
|
||||
<span>{feature}</span>
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
)}
|
||||
|
||||
{!isSubscribed && (
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
{props.options.map((option, i) => {
|
||||
const isSel = i === props.selected;
|
||||
return (
|
||||
<box
|
||||
id={getClinePassSubscriptionOptionId(i)}
|
||||
key={option.value}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : defaultFg}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
)}
|
||||
|
||||
{props.openStatus && (
|
||||
<text fg="gray" selectable flexShrink={0}>
|
||||
{props.openStatus}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{!isSubscribed && (
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
<text fg="gray" flexShrink={0}>
|
||||
If the browser button does not work:
|
||||
</text>
|
||||
<text fg={palette.act} selectable flexShrink={0}>
|
||||
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
<text fg="gray" paddingX={1}>
|
||||
<em>↑/↓ navigate, Enter to select, Esc to go back, Ctrl+C to exit</em>
|
||||
</text>
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingModelPickerScreen(props: {
|
||||
activeProviderName: string;
|
||||
compact: boolean;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useOnboardingController } from "./controller";
|
||||
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
@@ -121,6 +122,24 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "cline_pass_subscription") {
|
||||
return (
|
||||
<OnboardingClinePassSubscriptionScreen
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
currentPlanName={state.clinePassCurrentPlanName}
|
||||
error={state.clinePassSubscriptionError}
|
||||
mouse={mouse}
|
||||
openStatus={state.clinePassSubscriptionOpenStatus}
|
||||
options={state.clinePassSubscriptionOptions}
|
||||
planFeatures={state.clinePassPlanFeatures}
|
||||
selected={state.clinePassSubscriptionSelected}
|
||||
status={state.clinePassSubscriptionStatus}
|
||||
subscriptionUrl={state.clinePassSubscriptionUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "model_picker") {
|
||||
return (
|
||||
<OnboardingModelPickerScreen
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
@@ -10,9 +11,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
@@ -21,6 +24,14 @@ export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
|
||||
@@ -61,7 +61,7 @@ export function truncate(str: string, maxLen: number): string {
|
||||
return `${oneLine.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
function formatStructuredCommand(cmd: unknown): string {
|
||||
export function formatStructuredCommand(cmd: unknown): string {
|
||||
if (typeof cmd === "string") {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
@@ -13,20 +12,13 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
it("enables ClinePass when listing the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
|
||||
@@ -2,13 +2,11 @@ import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,12 +77,17 @@ function getOwnServerRecord(
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
function mutateServers(
|
||||
mutate: (servers: Record<string, unknown>) => void,
|
||||
): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
@@ -98,7 +103,9 @@ export function removeServer(name: string): boolean {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
@@ -126,7 +133,9 @@ export function clearServerOAuth(name: string): void {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
|
||||
@@ -85,7 +85,8 @@ export function setMcpServerDisabled(
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
@@ -128,7 +129,8 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -143,7 +145,8 @@ export function deleteMcpServer(name: string): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I'll inspect the file." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "src/index.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "result-block-1",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "I'll inspect the file.",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: { path: "src/index.ts" },
|
||||
output: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "I'll inspect the file.",
|
||||
},
|
||||
{
|
||||
id: "assistant-1:tool:toolu_1",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
output: "export const value = 1;",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates error tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "missing.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "File not found",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-error",
|
||||
output: "File not found",
|
||||
error: "File not found",
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "toolu_1",
|
||||
state: "output-error",
|
||||
error: "File not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates orphan tool results as standalone meta tool blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_orphan",
|
||||
name: "read_file",
|
||||
content: "orphan output",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "user-1",
|
||||
role: "meta",
|
||||
text: "",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_orphan",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "user-1:tool:toolu_orphan",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_orphan",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates plain string content as a text block", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Plain response",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "Plain response",
|
||||
reasoning: undefined,
|
||||
reasoningRedacted: undefined,
|
||||
toolEvents: undefined,
|
||||
blocks: [
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "Plain response",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates same-message tool-call and tool-result blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
input: { query: "cline" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "call_1",
|
||||
name: "search",
|
||||
state: "output-available",
|
||||
input: { query: "cline" },
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks).toHaveLength(1);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "call_1",
|
||||
state: "output-available",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { HubContext } from "./state";
|
||||
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
asTimestamp,
|
||||
basename,
|
||||
@@ -88,27 +89,291 @@ function summarizeClient(client: TrackedClient): {
|
||||
};
|
||||
}
|
||||
|
||||
type HistoryToolLocation = {
|
||||
messageIndex: number;
|
||||
blockIndex: number;
|
||||
};
|
||||
|
||||
function historyContentParts(content: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => asRecord(part))
|
||||
.filter((part): part is Record<string, unknown> => Boolean(part));
|
||||
}
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function blockType(block: Record<string, unknown>): string {
|
||||
return asString(block.type)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function toolCallIdForCall(block: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(block.id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolCallIdForResult(
|
||||
block: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
return (
|
||||
asString(block.tool_use_id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolNameFor(block: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(block.name) ??
|
||||
asString(block.toolName) ??
|
||||
asString(block.tool_name) ??
|
||||
"tool"
|
||||
);
|
||||
}
|
||||
|
||||
function toolInputFor(block: Record<string, unknown>): unknown {
|
||||
return block.input ?? block.args ?? block.arguments;
|
||||
}
|
||||
|
||||
function toolOutputFor(block: Record<string, unknown>): unknown {
|
||||
return block.output ?? block.result ?? block.content;
|
||||
}
|
||||
|
||||
function isErrorToolResult(block: Record<string, unknown>): boolean {
|
||||
return (
|
||||
block.is_error === true || block.isError === true || block.error === true
|
||||
);
|
||||
}
|
||||
|
||||
function pushTextBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
textParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (!text) return;
|
||||
textParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:text:${partIndex}`,
|
||||
type: "text",
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
function pushReasoningBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
reasoningParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
redacted?: boolean,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
reasoningParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:reasoning:${partIndex}`,
|
||||
type: "reasoning",
|
||||
text,
|
||||
redacted,
|
||||
});
|
||||
return redacted === true;
|
||||
}
|
||||
|
||||
export function mapHistoryToWebviewMessages(
|
||||
history: unknown[],
|
||||
): WebviewChatMessage[] {
|
||||
return history.map((entry, index) => {
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<string, HistoryToolLocation>();
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const messageKey = asString(record.id) ?? `history-${index}`;
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
const role: WebviewChatMessage["role"] =
|
||||
let role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
? rawRole
|
||||
: "meta";
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
return {
|
||||
id: asString(record.id) ?? `history-${index}`,
|
||||
const blocks: NonNullable<WebviewChatMessage["blocks"]> = [];
|
||||
const textParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const toolEvents = new Map<
|
||||
string,
|
||||
NonNullable<WebviewChatMessage["toolEvents"]>[number]
|
||||
>();
|
||||
const currentToolBlockIndexes = new Map<string, number>();
|
||||
let reasoningRedacted = false;
|
||||
|
||||
const contentParts = historyContentParts(record.content);
|
||||
if (contentParts.length === 0) {
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
pushTextBlock(blocks, textParts, messageKey, 0, text);
|
||||
}
|
||||
|
||||
for (const [partIndex, part] of contentParts.entries()) {
|
||||
const type = blockType(part);
|
||||
if (type === "text") {
|
||||
pushTextBlock(
|
||||
blocks,
|
||||
textParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.text) ?? asString(part.content) ?? "",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.thinking) ??
|
||||
asString(part.reasoning) ??
|
||||
asString(part.text) ??
|
||||
"",
|
||||
part.redacted === true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "redacted_thinking") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
"[redacted]",
|
||||
true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use" || type === "tool-call") {
|
||||
const toolCallId =
|
||||
toolCallIdForCall(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const toolEvent = {
|
||||
id: `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name,
|
||||
text: `Running ${name}...`,
|
||||
state: "input-available" as const,
|
||||
input: toolInputFor(part),
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
currentToolBlockIndexes.set(toolCallId, blocks.length - 1);
|
||||
toolLocations.set(toolCallId, {
|
||||
messageIndex: mapped.length,
|
||||
blockIndex: blocks.length - 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool-result") {
|
||||
const toolCallId =
|
||||
toolCallIdForResult(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const output = toolOutputFor(part);
|
||||
const isError = isErrorToolResult(part);
|
||||
const currentBlockIndex = currentToolBlockIndexes.get(toolCallId);
|
||||
const existingLocation = toolLocations.get(toolCallId);
|
||||
const existing =
|
||||
currentBlockIndex !== undefined
|
||||
? blocks[currentBlockIndex]
|
||||
: existingLocation !== undefined
|
||||
? mapped[existingLocation.messageIndex]?.blocks?.[
|
||||
existingLocation.blockIndex
|
||||
]
|
||||
: undefined;
|
||||
const existingToolEvent =
|
||||
existing?.type === "tool" ? existing.toolEvent : undefined;
|
||||
const toolEvent = {
|
||||
id: existingToolEvent?.id ?? `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name: existingToolEvent?.name ?? name,
|
||||
text: isError
|
||||
? `${existingToolEvent?.name ?? name} failed`
|
||||
: `${existingToolEvent?.name ?? name} completed`,
|
||||
state: isError
|
||||
? ("output-error" as const)
|
||||
: ("output-available" as const),
|
||||
input: existingToolEvent?.input,
|
||||
output,
|
||||
error: isError ? stringifyContent(output) : undefined,
|
||||
};
|
||||
|
||||
if (currentBlockIndex !== undefined && existing?.type === "tool") {
|
||||
blocks[currentBlockIndex] = {
|
||||
...existing,
|
||||
toolEvent,
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
} else if (
|
||||
existingLocation !== undefined &&
|
||||
existing?.type === "tool"
|
||||
) {
|
||||
const target = mapped[existingLocation.messageIndex];
|
||||
const targetBlocks = target.blocks;
|
||||
const targetBlock = targetBlocks?.[existingLocation.blockIndex];
|
||||
if (targetBlocks && targetBlock?.type === "tool") {
|
||||
targetBlocks[existingLocation.blockIndex] = {
|
||||
...targetBlock,
|
||||
toolEvent,
|
||||
};
|
||||
}
|
||||
target.toolEvents = (target.toolEvents ?? []).map((event) =>
|
||||
event.toolCallId === toolCallId ? toolEvent : event,
|
||||
);
|
||||
} else {
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join("\n");
|
||||
const toolEventList = [...toolEvents.values()];
|
||||
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!text && role === "user" && toolEventList.length > 0) {
|
||||
role = "meta";
|
||||
}
|
||||
mapped.push({
|
||||
id: messageKey,
|
||||
role,
|
||||
text,
|
||||
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
|
||||
};
|
||||
});
|
||||
reasoning:
|
||||
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function trackSession(record: unknown): TrackedSession | undefined {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageFrame({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
<div className={cn("max-w-344", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ProviderListContent({
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Models
|
||||
Model Providers
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Configure model providers and choose which ones are available.{" "}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -1043,7 +1038,8 @@ export async function handleCommand(
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
@@ -1094,7 +1090,8 @@ export async function handleCommand(
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -1106,7 +1103,8 @@ export async function handleCommand(
|
||||
if (command === "delete_mcp_server") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[String(args?.name ?? "")];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -284,6 +284,10 @@ message Settings {
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional string plan_mode_cline_pass_model_id = 183;
|
||||
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
|
||||
optional string act_mode_cline_pass_model_id = 185;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -425,6 +429,7 @@ message UpdateSettingsRequest {
|
||||
optional bool opt_out_of_remote_config = 39;
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional string compaction_strategy = 44;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -237,6 +237,16 @@ message ShowWebviewEvent {
|
||||
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
|
||||
}
|
||||
|
||||
message IntentEvent {
|
||||
string action = 1;
|
||||
string source = 2;
|
||||
bool has_text = 3;
|
||||
bool has_images = 4;
|
||||
bool has_files = 5;
|
||||
bool has_active_task = 6;
|
||||
int32 text_length = 7;
|
||||
}
|
||||
|
||||
// UiService provides methods for managing UI interactions
|
||||
service UiService {
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
@@ -292,4 +302,7 @@ service UiService {
|
||||
|
||||
// Opens the Cline walkthrough
|
||||
rpc openWalkthrough(EmptyRequest) returns (Empty);
|
||||
|
||||
// Tracks intent signals before task creation or first model activity
|
||||
rpc trackIntent(IntentEvent) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -14,18 +14,66 @@ import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const isWindows = process.platform === "win32"
|
||||
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
|
||||
// Resolve the grpc-tools package root via its package.json (stable regardless of `main`), so we
|
||||
// can both locate the bundled protoc and re-run its install script when the binary is missing.
|
||||
const GRPC_TOOLS_DIR = path.dirname(require.resolve("grpc-tools/package.json"))
|
||||
const GRPC_TOOLS_PROTOC = path.join(GRPC_TOOLS_DIR, "bin", isWindows ? "protoc.exe" : "protoc")
|
||||
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
|
||||
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
|
||||
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
|
||||
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
|
||||
|
||||
// `bun install` skips grpc-tools' `install` lifecycle script (`node-pre-gyp install`), so the prebuilt
|
||||
// protoc is never downloaded into bin/. When it's missing, run that same command here to fetch it.
|
||||
// grpc-tools depends on @mapbox/node-pre-gyp, which exposes the `node-pre-gyp` CLI.
|
||||
function resolveNodePreGypCli() {
|
||||
const candidates = ["@mapbox/node-pre-gyp/bin/node-pre-gyp", "node-pre-gyp/bin/node-pre-gyp"]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
// Resolve from the grpc-tools package (its direct dependency).
|
||||
return require.resolve(candidate, { paths: [GRPC_TOOLS_DIR] })
|
||||
} catch {
|
||||
// Fall back to resolving from this script's location (covers hoisted installs).
|
||||
try {
|
||||
return require.resolve(candidate)
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ensureProtocBinary() {
|
||||
console.warn(chalk.yellow(`protoc not found at ${GRPC_TOOLS_PROTOC}; downloading the grpc-tools prebuilt binary...`))
|
||||
const nodePreGypCli = resolveNodePreGypCli()
|
||||
if (!nodePreGypCli) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Could not resolve the node-pre-gyp CLI from ${GRPC_TOOLS_DIR}. Run \`bun install\`, then retry \`bun run protos\`.`,
|
||||
),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
try {
|
||||
// Mirrors grpc-tools' `scripts.install` ("node-pre-gyp install"): downloads the prebuilt
|
||||
// protoc for the current platform/arch into grpc-tools/bin.
|
||||
execFileSync(process.execPath, [nodePreGypCli, "install"], { cwd: GRPC_TOOLS_DIR, stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Failed to download protoc via node-pre-gyp: ${error?.message ?? error}`))
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fsSync.existsSync(GRPC_TOOLS_PROTOC)) {
|
||||
console.error(chalk.red(`protoc still not found at ${GRPC_TOOLS_PROTOC} after node-pre-gyp install.`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(chalk.green("✓ protoc binary installed."))
|
||||
}
|
||||
|
||||
if (!fsSync.existsSync(PROTOC)) {
|
||||
const windowsHint = isWindows
|
||||
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
|
||||
: ""
|
||||
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
|
||||
process.exit(1)
|
||||
// PROTOC only differs from GRPC_TOOLS_PROTOC when the legacy Windows path exists, so a missing
|
||||
// PROTOC always means the grpc-tools-bundled protoc needs to be fetched.
|
||||
ensureProtocBinary()
|
||||
}
|
||||
|
||||
const PROTO_DIR = path.resolve("proto")
|
||||
|
||||
@@ -61,33 +61,36 @@ async function main() {
|
||||
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
|
||||
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
|
||||
|
||||
// Single source of truth for the bun-vs-integration test split: any *.test.ts that
|
||||
// imports from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts)
|
||||
// and must NOT be compiled into the Node-based @vscode/test-cli `out/` tree (Node
|
||||
// cannot load the `bun:test` builtin, and these files use bun-only APIs like
|
||||
// `mock.module` / 3-arg `it`). Generate a tsconfig that excludes them so the
|
||||
// Single source of truth for the test-runner split: any *.test.ts that imports
|
||||
// from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts),
|
||||
// and any that imports from "vitest" is owned by the vitest runner (test:vitest,
|
||||
// see vitest.config.ts). Neither may be compiled into the Node-based
|
||||
// @vscode/test-cli `out/` tree: Node cannot load `bun:test`, and vitest suites
|
||||
// use vitest-only APIs/matchers (e.g. `toHaveBeenCalledWith`) that the mocha
|
||||
// runner does not provide. Generate a tsconfig that excludes them so the
|
||||
// integration compile only ever sees mocha-owned tests.
|
||||
const projectRoot = path.join(__dirname, "..")
|
||||
const bunTestImport = /from\s+["']bun:test["']/
|
||||
function collectBunTestFiles(dir, acc) {
|
||||
const nonMochaTestImport =
|
||||
/from\s+["'](?:bun:test|vitest(?:\/[^"']*)?|@vitest\/[^"']*)["']/
|
||||
function collectNonMochaTestFiles(dir, acc) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules") continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
collectBunTestFiles(full, acc)
|
||||
collectNonMochaTestFiles(full, acc)
|
||||
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
|
||||
if (bunTestImport.test(fs.readFileSync(full, "utf-8"))) {
|
||||
if (nonMochaTestImport.test(fs.readFileSync(full, "utf-8"))) {
|
||||
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
const bunOwnedTests = collectBunTestFiles(path.join(projectRoot, "src"), [])
|
||||
const nonMochaOwnedTests = collectNonMochaTestFiles(path.join(projectRoot, "src"), [])
|
||||
// tsconfig.test.json is JSONC (contains comments); parse with json5 (a project dep).
|
||||
const JSON5 = require("json5")
|
||||
const baseTestConfig = JSON5.parse(fs.readFileSync(path.join(projectRoot, "tsconfig.test.json"), "utf-8"))
|
||||
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...bunOwnedTests]
|
||||
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...nonMochaOwnedTests]
|
||||
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
|
||||
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ function inferProtoType(typeText, fieldName) {
|
||||
["FocusChainSettings", "FocusChainSettings"],
|
||||
["OpenaiReasoningEffort", "OpenaiReasoningEffort"],
|
||||
["PlanActMode", "PlanActMode"],
|
||||
["ApiProvider", "ApiProvider"],
|
||||
["ApiProvider", "string"],
|
||||
["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check
|
||||
]
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// This allows the SdkController to reuse the classic state-building logic
|
||||
// without inheriting the entire classic Controller implementation.
|
||||
|
||||
import { readCompactionStrategyGlobally } from "@cline/core"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { ClineEnv } from "@/config"
|
||||
@@ -40,6 +41,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
const mode = stateManager.getGlobalSettingsKey("mode")
|
||||
const yoloModeToggled = stateManager.getGlobalSettingsKey("yoloModeToggled")
|
||||
const useAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const subagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const userInfo = stateManager.getGlobalStateKey("userInfo")
|
||||
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
|
||||
@@ -118,6 +120,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
mode,
|
||||
yoloModeToggled,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
subagentsEnabled,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { AutoApprovalSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { Controller } from ".."
|
||||
import { updateAutoApprovalSettings } from "./updateAutoApprovalSettings"
|
||||
|
||||
function makeController(currentSettings = DEFAULT_AUTO_APPROVAL_SETTINGS, taskId?: string) {
|
||||
const controller = {
|
||||
task: taskId ? { taskId } : undefined,
|
||||
getStateToPostToWebview: vi.fn(async () => ({
|
||||
autoApprovalSettings: currentSettings,
|
||||
})),
|
||||
postStateToWebview: vi.fn(async () => undefined),
|
||||
stateManager: {
|
||||
setGlobalState: vi.fn(),
|
||||
setTaskSettings: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
return controller as unknown as Controller & {
|
||||
getStateToPostToWebview: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
stateManager: {
|
||||
setGlobalState: ReturnType<typeof vi.fn>
|
||||
setTaskSettings: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("updateAutoApprovalSettings", () => {
|
||||
it("updates the active task override when auto-approval settings change", async () => {
|
||||
const currentSettings = {
|
||||
...DEFAULT_AUTO_APPROVAL_SETTINGS,
|
||||
version: 1,
|
||||
actions: {
|
||||
...DEFAULT_AUTO_APPROVAL_SETTINGS.actions,
|
||||
editFiles: false,
|
||||
},
|
||||
}
|
||||
const controller = makeController(currentSettings, "task-1")
|
||||
|
||||
await updateAutoApprovalSettings(
|
||||
controller,
|
||||
AutoApprovalSettingsRequest.create({
|
||||
version: 2,
|
||||
actions: {
|
||||
editFiles: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const expectedSettings = {
|
||||
...currentSettings,
|
||||
version: 2,
|
||||
actions: {
|
||||
...currentSettings.actions,
|
||||
editFiles: true,
|
||||
},
|
||||
}
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls).toEqual([["autoApprovalSettings", expectedSettings]])
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls).toEqual([["task-1", "autoApprovalSettings", expectedSettings]])
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
it("does not create a task override when no task is active", async () => {
|
||||
const controller = makeController(DEFAULT_AUTO_APPROVAL_SETTINGS)
|
||||
|
||||
await updateAutoApprovalSettings(
|
||||
controller,
|
||||
AutoApprovalSettingsRequest.create({
|
||||
version: DEFAULT_AUTO_APPROVAL_SETTINGS.version + 1,
|
||||
actions: {
|
||||
readFiles: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(1)
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
it("ignores stale auto-approval settings versions", async () => {
|
||||
const controller = makeController(
|
||||
{
|
||||
...DEFAULT_AUTO_APPROVAL_SETTINGS,
|
||||
version: 3,
|
||||
},
|
||||
"task-1",
|
||||
)
|
||||
|
||||
await updateAutoApprovalSettings(
|
||||
controller,
|
||||
AutoApprovalSettingsRequest.create({
|
||||
version: 3,
|
||||
actions: {
|
||||
readFiles: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(0)
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@ export async function updateAutoApprovalSettings(controller: Controller, request
|
||||
}
|
||||
|
||||
controller.stateManager.setGlobalState("autoApprovalSettings", settings)
|
||||
if (controller.task?.taskId) {
|
||||
controller.stateManager.setTaskSettings(controller.task.taskId, "autoApprovalSettings", settings)
|
||||
}
|
||||
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setCompactionStrategyGlobally } from "@cline/core"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -179,6 +180,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
|
||||
}
|
||||
|
||||
if (request.compactionStrategy !== undefined) {
|
||||
const strategy = request.compactionStrategy
|
||||
if (strategy !== "basic" && strategy !== "agentic") {
|
||||
throw new Error(`Invalid compaction strategy value: ${strategy}`)
|
||||
}
|
||||
setCompactionStrategyGlobally(strategy)
|
||||
}
|
||||
|
||||
// Update custom prompt choice
|
||||
if (request.customPrompt !== undefined) {
|
||||
const value = request.customPrompt === "compact" ? "compact" : undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, UpdateSettingsRequestCli } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import { Settings } from "@shared/storage/state-keys"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import type { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
export async function trackIntent(_controller: Controller, request: IntentEvent): Promise<Empty> {
|
||||
switch (request.action) {
|
||||
case "new_task_clicked":
|
||||
telemetryService.captureNewTaskClicked(request.source, request.hasActiveTask)
|
||||
break
|
||||
case "prompt_submitted":
|
||||
telemetryService.capturePromptSubmitted({
|
||||
source: request.source,
|
||||
hasText: request.hasText,
|
||||
hasImages: request.hasImages,
|
||||
hasFiles: request.hasFiles,
|
||||
hasActiveTask: request.hasActiveTask,
|
||||
textLength: request.textLength,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
return Empty.create({})
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.PlusButton, async () => {
|
||||
const sidebarInstance = WebviewProvider.getInstance()
|
||||
telemetryService.captureNewTaskClicked("activity_bar_plus", !!sidebarInstance.controller.task)
|
||||
await sidebarInstance.controller.clearTask()
|
||||
await sidebarInstance.controller.postStateToWebview()
|
||||
await sendChatButtonClickedEvent()
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as vscode from "vscode"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WebviewMessage } from "@/shared/WebviewMessage"
|
||||
@@ -67,6 +68,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
// Sets up an event listener to listen for messages passed from the webview view context
|
||||
// and executes code based on the message that is received
|
||||
this.setWebviewMessageListener(webviewView.webview)
|
||||
telemetryService.capturePanelOpened("sidebar_resolved")
|
||||
|
||||
// Logs show up in bottom panel > Debug Console
|
||||
//Logger.log("registering listener")
|
||||
@@ -80,6 +82,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
webviewView.onDidChangeVisibility(
|
||||
async () => {
|
||||
if (this.webview?.visible) {
|
||||
telemetryService.capturePanelOpened("sidebar_visible")
|
||||
// View becoming visible should not steal editor focus.
|
||||
await sendShowWebviewEvent(true)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { isClineProvider } from "@/shared/utils/cline"
|
||||
import { resolveWorkspaceRootPath } from "./workspace-root"
|
||||
|
||||
describe("isClineProvider", () => {
|
||||
it("treats both Cline account providers as Cline providers", () => {
|
||||
expect(isClineProvider("cline")).toBe(true)
|
||||
expect(isClineProvider("cline-pass")).toBe(true)
|
||||
expect(isClineProvider("anthropic")).toBe(false)
|
||||
expect(isClineProvider(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveWorkspaceRootPath", () => {
|
||||
it("uses the first non-empty workspace path when available", () => {
|
||||
expect(resolveWorkspaceRootPath(["", "/workspace"], "/Users/tester/Desktop")).toBe("/workspace")
|
||||
|
||||
@@ -44,6 +44,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import type { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineProvider } from "@/shared/utils/cline"
|
||||
import { arePathsEqual, getDesktopDir } from "@/utils/path"
|
||||
import { ClineAccountService } from "./account-service"
|
||||
import { AuthService, LogoutReason } from "./auth-service"
|
||||
@@ -53,6 +54,12 @@ import { createProviderCatalog } from "./model-catalog/catalog"
|
||||
import type { Disposable, ProviderCatalog, ProviderConfigChange, ProviderConfigStore } from "./model-catalog/contracts"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { createProviderConfigStore } from "./model-catalog/store"
|
||||
import {
|
||||
PROVIDER_FAILURE_ERROR_TYPE,
|
||||
PROVIDER_FAILURE_PHASE,
|
||||
type ProviderFailureTelemetry,
|
||||
ProviderFailureTelemetryTurnGate,
|
||||
} from "./provider-failure-telemetry"
|
||||
import {
|
||||
findVisibleCheckpointUserMessageByRun,
|
||||
getCheckpointRunCountForMessage,
|
||||
@@ -159,6 +166,7 @@ export class Controller {
|
||||
private sessionEvents: SdkSessionEventCoordinator
|
||||
private sessionHistory: SdkSessionHistoryLoader
|
||||
private readonly sdkTelemetry: VscodeSdkTelemetryHandle
|
||||
private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate()
|
||||
private readonly providerConfigStore: ProviderConfigStore
|
||||
private readonly providerCatalog: ProviderCatalog
|
||||
private readonly providerConfigStoreSubscription: Disposable
|
||||
@@ -302,6 +310,9 @@ export class Controller {
|
||||
}
|
||||
return this._terminalManager
|
||||
},
|
||||
onSendStart: () => {
|
||||
this.beginProviderFailureTelemetryTurn()
|
||||
},
|
||||
onSendComplete: async () => {
|
||||
await this.providerChanges.handleTurnComplete(this.mode)
|
||||
|
||||
@@ -313,17 +324,39 @@ export class Controller {
|
||||
// A turn failed — the UI shows error recovery (Retry / Sign In / Add Credits).
|
||||
this.turnStateTracker.set("error")
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const providerId = this.getSessionProviderId(sessionId) ?? this.getActiveProviderId()
|
||||
const isClineAuthError =
|
||||
this.isClineProviderActive() &&
|
||||
isClineProvider(providerId) &&
|
||||
(errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
|
||||
errorMessage.toLowerCase().includes("missing api key") ||
|
||||
errorMessage.toLowerCase().includes("unauthorized"))
|
||||
|
||||
if (isClineAuthError) {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
providerId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.AUTH,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
this.emitClineAuthError()
|
||||
} else if (this.isClineProviderActive() && this.isClineBalanceError(errorMessage)) {
|
||||
} else if (isClineProvider(providerId) && this.isClineBalanceError(errorMessage)) {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
providerId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.BALANCE,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
this.emitClineBalanceError(errorMessage)
|
||||
} else {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
providerId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SEND_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
this.messages.emitSessionEvents(
|
||||
[
|
||||
{
|
||||
@@ -360,7 +393,7 @@ export class Controller {
|
||||
loadInitialMessages: async (sdkHost, sessionId) =>
|
||||
(await this.sessionHistory.loadInitialMessages(sdkHost, sessionId)) ?? [],
|
||||
buildStartSessionInput,
|
||||
emitClineAuthError: () => this.emitClineAuthError(),
|
||||
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
getTurnPhase: () => this.turnStateTracker.currentPhase,
|
||||
@@ -411,7 +444,7 @@ export class Controller {
|
||||
buildStartSessionInput,
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
isClineProviderActive: () => this.isClineProviderActive(),
|
||||
emitClineAuthError: () => this.emitClineAuthError(),
|
||||
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
onResumeFailed: () => {
|
||||
@@ -460,7 +493,8 @@ export class Controller {
|
||||
loadInitialMessages: (reader, taskId) => this.sessionHistory.loadInitialMessages(reader, taskId),
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
isClineProviderActive: () => this.isClineProviderActive(),
|
||||
emitClineAuthError: (task) => this.emitClineAuthError(task),
|
||||
emitClineAuthError: (task) => this.emitClineAuthErrorWithTelemetry(task),
|
||||
captureProviderApiError: (event) => this.captureProviderFailure(event),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
})
|
||||
this.compaction = new SdkCompactionCoordinator({
|
||||
@@ -486,6 +520,8 @@ export class Controller {
|
||||
getTask: () => this.task,
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
|
||||
captureProviderApiError: (event) => this.captureProviderFailure(event),
|
||||
beginProviderFailureTelemetryTurn: () => this.beginProviderFailureTelemetryTurn(),
|
||||
})
|
||||
// Subscribe to MCP tool list changes so we can restart the SDK session
|
||||
// when servers are added/removed/reconnected. The SDK's DefaultSessionBuilder
|
||||
@@ -818,11 +854,78 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private getTaskModelId(): string | undefined {
|
||||
const modelId = this.task?.api?.getModel?.().id?.trim()
|
||||
return modelId && modelId !== "unknown" ? modelId : undefined
|
||||
}
|
||||
|
||||
private getSessionProviderId(sessionId?: string): string | undefined {
|
||||
const activeSession = this.sessions.getActiveSession()
|
||||
if (sessionId && activeSession?.sessionId !== sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const providerId =
|
||||
activeSession?.startResult?.manifest?.provider?.trim() || activeSession?.startConfig?.providerId?.trim()
|
||||
return providerId && providerId !== "unknown" ? providerId : undefined
|
||||
}
|
||||
|
||||
private getSessionModelId(sessionId?: string): string | undefined {
|
||||
const activeSession = this.sessions.getActiveSession()
|
||||
if (sessionId && activeSession?.sessionId !== sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const modelId = activeSession?.startResult?.manifest?.model?.trim() || activeSession?.startConfig?.modelId?.trim()
|
||||
return modelId && modelId !== "unknown" ? modelId : undefined
|
||||
}
|
||||
|
||||
private beginProviderFailureTelemetryTurn(): void {
|
||||
this.providerFailureTelemetryTurnGate.beginTurn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the active API provider is 'cline' (for current mode).
|
||||
* Check if the active API provider uses Cline account auth for the current mode.
|
||||
*/
|
||||
private isClineProviderActive(): boolean {
|
||||
return this.getActiveProviderId() === "cline"
|
||||
return isClineProvider(this.getActiveProviderId())
|
||||
}
|
||||
|
||||
private captureProviderFailure(event: ProviderFailureTelemetry): void {
|
||||
const ulid = event.sessionId ?? this.task?.taskId ?? this.sessions.getActiveSession()?.sessionId
|
||||
if (!ulid) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.failurePhase === PROVIDER_FAILURE_PHASE.STREAMING &&
|
||||
!this.providerFailureTelemetryTurnGate.shouldCaptureStreamingFailure()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = event.providerId ?? this.getSessionProviderId(event.sessionId) ?? "unknown"
|
||||
const model = event.modelId ?? this.getSessionModelId(event.sessionId) ?? this.getTaskModelId() ?? "unknown"
|
||||
const clineError = ClineError.transform(event.error, model, provider)
|
||||
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid,
|
||||
model,
|
||||
provider,
|
||||
errorMessage: clineError.message || String(event.error),
|
||||
errorStatus: clineError.status,
|
||||
requestId: clineError.requestId,
|
||||
errorType: event.errorType,
|
||||
failurePhase: event.failurePhase,
|
||||
})
|
||||
}
|
||||
|
||||
private emitClineAuthErrorWithTelemetry(task?: string, sessionId?: string): void {
|
||||
this.emitClineAuthError(task)
|
||||
this.captureProviderFailure({
|
||||
sessionId: sessionId ?? this.task?.taskId,
|
||||
error: CLINE_ACCOUNT_AUTH_ERROR_MESSAGE,
|
||||
providerId: this.getActiveProviderId(),
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.AUTH,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1140,7 +1243,7 @@ export class Controller {
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
|
||||
const config = await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle })
|
||||
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
|
||||
this.emitClineAuthError(editedText)
|
||||
this.emitClineAuthErrorWithTelemetry(editedText)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1242,7 +1345,7 @@ export class Controller {
|
||||
const historyTitle = checkpointRunCount === 1 ? restoredText : firstUserMessage?.text || restoredText
|
||||
const config = restoreMessages ? await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle }) : undefined
|
||||
if (config && usesClineAccountAuth(config.providerId) && !config.apiKey) {
|
||||
this.emitClineAuthError(restoredText)
|
||||
this.emitClineAuthErrorWithTelemetry(restoredText)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -71,9 +71,11 @@ vi.mock("@shared/services/Logger", () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tempDir: string
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
|
||||
vi.clearAllMocks()
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
@@ -91,6 +93,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -378,7 +381,7 @@ describe("buildSessionConfig", () => {
|
||||
const providers = [
|
||||
{ providerId: "poolside", modelId: "poolside/laguna-m.1" },
|
||||
{ providerId: "v0", modelId: "v0-1.5-md" },
|
||||
{ providerId: "xiaomi", modelId: "mimo-v2-omni" },
|
||||
{ providerId: "xiaomi", modelId: "mimo-v2.5" },
|
||||
{ providerId: "zai-coding-plan", modelId: "glm-5.2" },
|
||||
] as const
|
||||
|
||||
@@ -651,6 +654,46 @@ describe("buildSessionConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the configured SDK compaction strategy when auto condense is enabled", async () => {
|
||||
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "agentic" })
|
||||
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
|
||||
if (key === "useAutoCondense") {
|
||||
return true
|
||||
}
|
||||
if (key === "subagentsEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to basic SDK compaction for an invalid stored strategy", async () => {
|
||||
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "invalid" })
|
||||
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
|
||||
if (key === "useAutoCondense") {
|
||||
return true
|
||||
}
|
||||
if (key === "subagentsEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not enable SDK compaction when global useAutoCondense is false", async () => {
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type CoreSessionConfig,
|
||||
getProviderAuthHandler,
|
||||
type ProviderSettings,
|
||||
readCompactionStrategyGlobally,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
type StartSessionResult,
|
||||
} from "@cline/core"
|
||||
@@ -90,6 +91,8 @@ export interface SessionConfigInput {
|
||||
export interface ActiveSession {
|
||||
/** The session ID */
|
||||
sessionId: string
|
||||
/** The config used to start the active session. */
|
||||
startConfig?: Pick<CoreSessionConfig, "providerId" | "modelId">
|
||||
/** The runtime host instance managing this session (VscodeSessionHost) */
|
||||
sdkHost: SdkSessionHost
|
||||
/** Unsubscribe function for session events */
|
||||
@@ -653,6 +656,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
const globalUseAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense") ?? false
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting") ?? true
|
||||
const useAutoCondense = input.taskSettings?.useAutoCondense ?? globalUseAutoCondense
|
||||
|
||||
@@ -697,7 +701,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
? {
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
strategy: compactionStrategy,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("parseProviderId", () => {
|
||||
parseProviderId("poolside")
|
||||
parseProviderId("v0")
|
||||
parseProviderId("xiaomi")
|
||||
parseProviderId("tencent-tokenhub")
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -64,6 +65,7 @@ describe("isKnownProviderId", () => {
|
||||
expect(isKnownProviderId(parseProviderId("poolside"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("v0"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("xiaomi"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("tencent-tokenhub"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for a custom provider id", () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ const KNOWN_API_PROVIDERS = {
|
||||
nousResearch: true,
|
||||
wandb: true,
|
||||
xiaomi: true,
|
||||
"tencent-tokenhub": true,
|
||||
"cline-pass": true,
|
||||
} satisfies Record<ApiProvider, true>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { ProviderFailureTelemetryTurnGate } from "./provider-failure-telemetry"
|
||||
|
||||
describe("ProviderFailureTelemetryTurnGate", () => {
|
||||
it("captures one streaming failure per active turn", () => {
|
||||
const gate = new ProviderFailureTelemetryTurnGate()
|
||||
|
||||
gate.beginTurn()
|
||||
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(false)
|
||||
})
|
||||
|
||||
it("captures again when a new turn starts", () => {
|
||||
const gate = new ProviderFailureTelemetryTurnGate()
|
||||
|
||||
gate.beginTurn()
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(false)
|
||||
|
||||
gate.beginTurn()
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
})
|
||||
|
||||
it("does not suppress streaming failures when no turn is active", () => {
|
||||
const gate = new ProviderFailureTelemetryTurnGate()
|
||||
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export const PROVIDER_FAILURE_ERROR_TYPE = {
|
||||
AUTH: "auth",
|
||||
BALANCE: "balance",
|
||||
SEND_ERROR: "send_error",
|
||||
TASK_INIT: "task_init",
|
||||
SDK_AGENT_ERROR: "sdk_agent_error",
|
||||
SDK_AGENT_DONE_ERROR: "sdk_agent_done_error",
|
||||
} as const
|
||||
|
||||
export const PROVIDER_FAILURE_PHASE = {
|
||||
PREFLIGHT: "preflight",
|
||||
STREAMING: "streaming",
|
||||
} as const
|
||||
|
||||
export type ProviderFailureErrorType = (typeof PROVIDER_FAILURE_ERROR_TYPE)[keyof typeof PROVIDER_FAILURE_ERROR_TYPE]
|
||||
|
||||
export type ProviderFailurePhase = (typeof PROVIDER_FAILURE_PHASE)[keyof typeof PROVIDER_FAILURE_PHASE]
|
||||
|
||||
export type ProviderFailureTelemetry = {
|
||||
sessionId?: string
|
||||
error: unknown
|
||||
providerId?: string
|
||||
modelId?: string
|
||||
errorType: ProviderFailureErrorType
|
||||
failurePhase: ProviderFailurePhase
|
||||
}
|
||||
|
||||
export class ProviderFailureTelemetryTurnGate {
|
||||
private turnCounter = 0
|
||||
private activeTurnId: number | undefined
|
||||
private streamingFailureCapturedTurnId: number | undefined
|
||||
|
||||
beginTurn(): void {
|
||||
this.turnCounter += 1
|
||||
this.activeTurnId = this.turnCounter
|
||||
}
|
||||
|
||||
shouldCaptureStreamingFailure(): boolean {
|
||||
if (this.activeTurnId === undefined) {
|
||||
return true
|
||||
}
|
||||
if (this.streamingFailureCapturedTurnId === this.activeTurnId) {
|
||||
return false
|
||||
}
|
||||
this.streamingFailureCapturedTurnId = this.activeTurnId
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { CoreSessionEvent } from "@cline/core"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { MessageTranslatorState } from "./message-translator"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "./provider-failure-telemetry"
|
||||
import { SdkSessionEventCoordinator, type SdkSessionEventCoordinatorOptions } from "./sdk-session-event-coordinator"
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
@@ -135,6 +136,7 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(clearTurnOutcome).toHaveBeenCalledOnce()
|
||||
expect(options.beginProviderFailureTelemetryTurn).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
|
||||
@@ -263,6 +265,72 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
|
||||
expect(options.sessions.setRunning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("captures provider failure telemetry for SDK agent errors", async () => {
|
||||
const error = new Error("provider failed")
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "error",
|
||||
error,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).toHaveBeenCalledWith({
|
||||
sessionId: "session-123",
|
||||
error,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not capture provider failure telemetry for SDK agent errors without an error payload", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "error",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("captures provider failure telemetry when the SDK finishes a turn with reason error", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "done",
|
||||
reason: "error",
|
||||
text: "stream failed before assistant output",
|
||||
iterations: 1,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).toHaveBeenCalledWith({
|
||||
sessionId: "session-123",
|
||||
error: "stream failed before assistant output",
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
@@ -299,6 +367,8 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
getTask: vi.fn(() => input.task),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
setTurnPhase: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
beginProviderFailureTelemetryTurn: vi.fn(),
|
||||
translateSessionEvent: vi.fn(() => input.translation ?? { messages: [], sessionEnded: false, turnComplete: false }),
|
||||
isClineFreeModel: input.isClineFreeModel,
|
||||
} as unknown as SdkSessionEventCoordinatorOptions & {
|
||||
@@ -317,6 +387,8 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
}
|
||||
taskHistory: SdkSessionEventCoordinatorOptions["taskHistory"] & { updateTaskUsage: ReturnType<typeof vi.fn> }
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
captureProviderApiError: ReturnType<typeof vi.fn>
|
||||
beginProviderFailureTelemetryTurn: ReturnType<typeof vi.fn>
|
||||
translateSessionEvent: ReturnType<typeof vi.fn>
|
||||
messageTranslatorState: MessageTranslatorState
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CoreSessionEvent } from "@cline/core"
|
||||
import type { AgentEvent, CoreSessionEvent } from "@cline/core"
|
||||
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
@@ -6,6 +6,7 @@ import type { ClineApiReqInfo, TurnPhase } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { MessageTranslatorState, TranslationResult } from "./message-translator"
|
||||
import { translateSessionEvent } from "./message-translator"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
|
||||
import type { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import type { SdkModeCoordinator } from "./sdk-mode-coordinator"
|
||||
@@ -18,6 +19,8 @@ function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
type AgentFailureTelemetry = Pick<ProviderFailureTelemetry, "sessionId" | "error" | "errorType"> | undefined
|
||||
|
||||
export interface SdkSessionEventCoordinatorOptions {
|
||||
messageTranslatorState: MessageTranslatorState
|
||||
sessions: SdkSessionLifecycle
|
||||
@@ -37,6 +40,8 @@ export interface SdkSessionEventCoordinatorOptions {
|
||||
* error. Optional for tests.
|
||||
*/
|
||||
setTurnPhase?: (phase: TurnPhase, anchorTs?: number) => void
|
||||
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
|
||||
beginProviderFailureTelemetryTurn?: () => void
|
||||
}
|
||||
|
||||
export class SdkSessionEventCoordinator {
|
||||
@@ -64,10 +69,20 @@ export class SdkSessionEventCoordinator {
|
||||
}
|
||||
|
||||
const result = this.translateSessionEvent(event, this.options.messageTranslatorState)
|
||||
const agentFailure = this.getAgentFailureTelemetry(event)
|
||||
if (agentFailure && !this.options.messageTranslatorState.isSuppressedToolApprovalDenial(agentFailure.error)) {
|
||||
this.options.captureProviderApiError?.({
|
||||
sessionId: agentFailure.sessionId,
|
||||
error: agentFailure.error,
|
||||
errorType: agentFailure.errorType,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
}
|
||||
if (event.type === "pending_prompt_submitted") {
|
||||
this.options.beginProviderFailureTelemetryTurn?.()
|
||||
this.options.messageTranslatorState.clearTurnOutcome()
|
||||
this.options.sessions.setRunning(true)
|
||||
this.options.setTurnPhase?.("streaming")
|
||||
this.options.setTurnPhase?.(PROVIDER_FAILURE_PHASE.STREAMING)
|
||||
}
|
||||
const zeroCostPromise = this.zeroCostForFreeClineModel(result)
|
||||
if (zeroCostPromise) {
|
||||
@@ -147,6 +162,33 @@ export class SdkSessionEventCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private getAgentFailureTelemetry(event: CoreSessionEvent): AgentFailureTelemetry {
|
||||
if (event.type !== "agent_event") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agentEvent: AgentEvent = event.payload.event
|
||||
if (agentEvent.type === "error") {
|
||||
if (agentEvent.error == null) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sessionId: event.payload.sessionId,
|
||||
error: agentEvent.error,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_ERROR,
|
||||
}
|
||||
}
|
||||
if (agentEvent.type === "done" && agentEvent.reason === "error") {
|
||||
const errorMessage = agentEvent.text.trim() || "SDK agent finished with error"
|
||||
return {
|
||||
sessionId: event.payload.sessionId,
|
||||
error: errorMessage,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private zeroCostForFreeClineModel(result: TranslationResult): Promise<void> | undefined {
|
||||
const hasUsageCost = typeof result.usage?.totalCost === "number" && result.usage.totalCost !== 0
|
||||
const hasMessageCost = result.messages.some((message) => {
|
||||
|
||||
@@ -41,6 +41,24 @@ describe("SdkSessionLifecycle", () => {
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("stores the provider and model config used to start the active session", async () => {
|
||||
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
|
||||
await lifecycle.startNewSession({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4",
|
||||
},
|
||||
} as StartInput)
|
||||
|
||||
expect(lifecycle.getActiveSession()?.startConfig).toEqual({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4",
|
||||
})
|
||||
})
|
||||
|
||||
it("reuses the shared session host across sessions", async () => {
|
||||
const sdkHost = makeSdkHost({
|
||||
start: vi.fn().mockResolvedValueOnce({ sessionId: "session-1" }).mockResolvedValueOnce({ sessionId: "session-2" }),
|
||||
@@ -147,6 +165,23 @@ describe("SdkSessionLifecycle", () => {
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(false)
|
||||
})
|
||||
|
||||
it("calls the send-start hook before sending to the SDK host", async () => {
|
||||
const onSendStart = vi.fn()
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const sdkHost = makeSdkHost({ send })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle({ onSendStart })
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
await lifecycle.startNewSession({} as any)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
lifecycle.fireAndForgetSend(sdkHost as any, "session-123", "hello")
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalled())
|
||||
|
||||
expect(onSendStart).toHaveBeenCalledWith("session-123")
|
||||
expect(onSendStart.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
it("leaves the active session running when a message is queued", async () => {
|
||||
const onSendComplete = vi.fn()
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -428,8 +463,13 @@ describe("SdkSessionLifecycle", () => {
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
await lifecycle.startNewSession({ config: { sessionId: "source-session" } } as any)
|
||||
await lifecycle.startNewSession({
|
||||
config: {
|
||||
sessionId: "source-session",
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5",
|
||||
},
|
||||
} as StartInput)
|
||||
const result = await lifecycle.restoreActiveSession({
|
||||
sessionId: "source-session",
|
||||
checkpointRunCount: 1,
|
||||
@@ -437,6 +477,10 @@ describe("SdkSessionLifecycle", () => {
|
||||
|
||||
expect(result).toBe(restored)
|
||||
expect(lifecycle.getActiveSession()?.sessionId).toBe("restored-session")
|
||||
expect(lifecycle.getActiveSession()?.startConfig).toEqual({
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5",
|
||||
})
|
||||
expect(sdkHost.stop).toHaveBeenCalledWith("source-session")
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SdkSessionLifecycleOptions {
|
||||
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
|
||||
/** Shared SDK telemetry service owned by SdkController. */
|
||||
telemetry?: ITelemetryService
|
||||
onSendStart?: (sessionId: string) => void
|
||||
onSendComplete: (sessionId: string) => Promise<void> | void
|
||||
onSendError: (error: unknown, sessionId: string) => Promise<void> | void
|
||||
}
|
||||
@@ -126,6 +127,12 @@ export class SdkSessionLifecycle {
|
||||
})
|
||||
this.activeSession = {
|
||||
sessionId: startResult.sessionId,
|
||||
startConfig: startInput.config
|
||||
? {
|
||||
providerId: startInput.config.providerId,
|
||||
modelId: startInput.config.modelId,
|
||||
}
|
||||
: undefined,
|
||||
sdkHost,
|
||||
unsubscribe: () => {},
|
||||
startResult,
|
||||
@@ -182,6 +189,12 @@ export class SdkSessionLifecycle {
|
||||
this.activeSession = {
|
||||
...activeSession,
|
||||
sessionId: restored.sessionId,
|
||||
startConfig: input.start?.config
|
||||
? {
|
||||
providerId: input.start.config.providerId,
|
||||
modelId: input.start.config.modelId,
|
||||
}
|
||||
: activeSession.startConfig,
|
||||
startResult: restored.startResult,
|
||||
isRunning: false,
|
||||
}
|
||||
@@ -324,6 +337,7 @@ export class SdkSessionLifecycle {
|
||||
Logger.debug(`[SdkController] Ignoring ${label} of superseded send for session: ${sessionId}`)
|
||||
return true
|
||||
}
|
||||
this.options.onSendStart?.(sessionId)
|
||||
sdkHost
|
||||
.send({
|
||||
sessionId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "./provider-failure-telemetry"
|
||||
import { SdkTaskStartCoordinator, type SdkTaskStartCoordinatorOptions } from "./sdk-task-start-coordinator"
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
@@ -71,6 +72,7 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledWith("needs auth")
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -81,17 +83,27 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledWith("needs clinepass auth")
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("emits a plain chat error when session start fails (e.g. provider misconfigured)", async () => {
|
||||
const { coordinator, options, state } = makeCoordinator()
|
||||
options.sessions.startNewSession.mockRejectedValue(new Error("No model configured for provider openai"))
|
||||
const error = new Error("No model configured for provider openai")
|
||||
options.sessions.startNewSession.mockRejectedValue(error)
|
||||
|
||||
const sessionId = await coordinator.initTask("do something")
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).not.toHaveBeenCalled()
|
||||
expect(options.captureProviderApiError).toHaveBeenCalledWith({
|
||||
sessionId: state.task?.taskId,
|
||||
error,
|
||||
providerId: "anthropic",
|
||||
modelId: "model",
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.TASK_INIT,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
expect(state.task?.taskId).toEqual(expect.any(String))
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
@@ -242,6 +254,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
|
||||
isClineProviderActive: vi.fn(() => false),
|
||||
emitClineAuthError: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as SdkTaskStartCoordinatorOptions & {
|
||||
sessions: SdkTaskStartCoordinatorOptions["sessions"] & {
|
||||
@@ -266,6 +279,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
resolveContextMentions: ReturnType<typeof vi.fn>
|
||||
isClineProviderActive: ReturnType<typeof vi.fn>
|
||||
emitClineAuthError: ReturnType<typeof vi.fn>
|
||||
captureProviderApiError: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
|
||||
import type { SdkSessionLifecycle } from "./sdk-session-lifecycle"
|
||||
@@ -52,6 +53,7 @@ export interface SdkTaskStartCoordinatorOptions {
|
||||
resolveContextMentions: (text: string) => Promise<string>
|
||||
isClineProviderActive: () => boolean
|
||||
emitClineAuthError: (task?: string) => void
|
||||
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -67,6 +69,8 @@ export class SdkTaskStartCoordinator {
|
||||
): Promise<string | undefined> {
|
||||
Logger.log(`[SdkController] initTask called: "${prompt?.substring(0, 50)}"`)
|
||||
let taskSessionId: string | undefined
|
||||
let providerId: string | undefined
|
||||
let modelId: string | undefined
|
||||
try {
|
||||
await this.options.clearTask()
|
||||
|
||||
@@ -82,6 +86,8 @@ export class SdkTaskStartCoordinator {
|
||||
cwd,
|
||||
mode,
|
||||
})
|
||||
providerId = config.providerId
|
||||
modelId = config.modelId
|
||||
|
||||
Logger.log(
|
||||
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
|
||||
@@ -91,6 +97,8 @@ export class SdkTaskStartCoordinator {
|
||||
Logger.warn(
|
||||
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
|
||||
)
|
||||
// No task/session id exists yet, so this preflight auth UI path is
|
||||
// intentionally not recorded as task-joinable provider error telemetry.
|
||||
this.options.emitClineAuthError(prompt)
|
||||
return undefined
|
||||
}
|
||||
@@ -141,6 +149,14 @@ export class SdkTaskStartCoordinator {
|
||||
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
|
||||
return taskSessionId
|
||||
} catch (error) {
|
||||
this.options.captureProviderApiError?.({
|
||||
sessionId: taskSessionId,
|
||||
error,
|
||||
providerId,
|
||||
modelId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.TASK_INIT,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
this.handleInitError(error, taskSessionId)
|
||||
await this.options.postStateToWebview().catch((postError) => {
|
||||
Logger.error("[SdkController] Failed to post state after init error:", postError)
|
||||
|
||||
@@ -109,6 +109,14 @@ export class ClineError extends Error {
|
||||
})
|
||||
}
|
||||
|
||||
public get status(): number | undefined {
|
||||
return this._error.status
|
||||
}
|
||||
|
||||
public get requestId(): string | undefined {
|
||||
return this._error.request_id
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a stringified error into a ClineError instance.
|
||||
*/
|
||||
|
||||
@@ -338,6 +338,12 @@ export class TelemetryService {
|
||||
MODEL_FAVORITE_TOGGLED: "ui.model_favorite_toggled",
|
||||
// Tracks when a button is clicked
|
||||
BUTTON_CLICKED: "ui.button_clicked",
|
||||
// Tracks when the Cline panel becomes visible
|
||||
PANEL_OPENED: "ui.panel_opened",
|
||||
// Tracks when the user explicitly starts a new task flow
|
||||
NEW_TASK_CLICKED: "ui.new_task_clicked",
|
||||
// Tracks when the user submits chat composer content
|
||||
PROMPT_SUBMITTED: "ui.prompt_submitted",
|
||||
// Tracks when the rules menu button is clicked
|
||||
RULES_MENU_OPENED: "ui.rules_menu_opened",
|
||||
},
|
||||
@@ -1370,6 +1376,34 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
public capturePanelOpened(source?: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.UI.PANEL_OPENED,
|
||||
properties: { source },
|
||||
})
|
||||
}
|
||||
|
||||
public captureNewTaskClicked(source?: string, hasActiveTask?: boolean) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.UI.NEW_TASK_CLICKED,
|
||||
properties: { source, hasActiveTask },
|
||||
})
|
||||
}
|
||||
|
||||
public capturePromptSubmitted(args: {
|
||||
source?: string
|
||||
hasText?: boolean
|
||||
hasImages?: boolean
|
||||
hasFiles?: boolean
|
||||
hasActiveTask?: boolean
|
||||
textLength?: number
|
||||
}) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.UI.PROMPT_SUBMITTED,
|
||||
properties: args,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records telemetry when an API provider returns an error
|
||||
* @param ulid Unique identifier for the task
|
||||
@@ -1386,6 +1420,8 @@ export class TelemetryService {
|
||||
provider?: string
|
||||
errorStatus?: number | undefined
|
||||
requestId?: string | undefined
|
||||
errorType?: string | undefined
|
||||
failurePhase?: string | undefined
|
||||
isNativeToolCall?: boolean
|
||||
}) {
|
||||
this.capture({
|
||||
@@ -1402,12 +1438,16 @@ export class TelemetryService {
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
error_status: args.errorStatus,
|
||||
error_type: args.errorType,
|
||||
failure_phase: args.failurePhase,
|
||||
})
|
||||
const errorAttributes = {
|
||||
ulid: args.ulid,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
error_status: args.errorStatus,
|
||||
error_type: args.errorType,
|
||||
failure_phase: args.failurePhase,
|
||||
}
|
||||
const errorCount = this.incrementTaskCounter(this.taskErrorCounts, args.ulid)
|
||||
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "bun:test"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import * as assert from "assert"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "../../../sdk/provider-failure-telemetry"
|
||||
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
|
||||
import { TelemetryMetadata, TelemetryService } from "../TelemetryService"
|
||||
|
||||
@@ -288,6 +289,8 @@ describe("TelemetryService metrics", () => {
|
||||
errorMessage: "boom",
|
||||
provider: "anthropic",
|
||||
errorStatus: 500,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
|
||||
assert.strictEqual(provider.counters.length, 1)
|
||||
@@ -298,6 +301,8 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(entry.attributes.provider, "anthropic")
|
||||
assert.strictEqual(entry.attributes.model, "claude")
|
||||
assert.strictEqual(entry.attributes.error_status, 500)
|
||||
assert.strictEqual(entry.attributes.error_type, PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR)
|
||||
assert.strictEqual(entry.attributes.failure_phase, PROVIDER_FAILURE_PHASE.STREAMING)
|
||||
assert.strictEqual(provider.histograms.length, 1)
|
||||
const errorHistogram = provider.histograms[0]
|
||||
assert.strictEqual(errorHistogram.name, TelemetryService.METRICS.ERRORS.PER_TASK)
|
||||
@@ -306,6 +311,8 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(errorHistogram.attributes.provider, "anthropic")
|
||||
assert.strictEqual(errorHistogram.attributes.model, "claude")
|
||||
assert.strictEqual(errorHistogram.attributes.error_status, 500)
|
||||
assert.strictEqual(errorHistogram.attributes.error_type, PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR)
|
||||
assert.strictEqual(errorHistogram.attributes.failure_phase, PROVIDER_FAILURE_PHASE.STREAMING)
|
||||
})
|
||||
|
||||
it("captureTaskCompleted records completion payload with TTFT and duration histograms", () => {
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface ExtensionState {
|
||||
mcpResponsesCollapsed?: boolean
|
||||
yoloModeToggled?: boolean
|
||||
useAutoCondense?: boolean
|
||||
compactionStrategy?: string
|
||||
subagentsEnabled?: boolean
|
||||
worktreesEnabled?: ClineFeatureSetting
|
||||
customPrompt?: string
|
||||
|
||||
@@ -49,6 +49,7 @@ export type ApiProvider =
|
||||
| "nousResearch"
|
||||
| "wandb"
|
||||
| "xiaomi"
|
||||
| "tencent-tokenhub"
|
||||
|
||||
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { convertApiConfigurationToProto, convertProtoToApiConfiguration } from "
|
||||
|
||||
describe("api configuration provider conversion", () => {
|
||||
it("round-trips SDK provider ids added after the legacy enum list", () => {
|
||||
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "zai-coding-plan"]
|
||||
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "tencent-tokenhub", "zai-coding-plan"]
|
||||
|
||||
for (const provider of providers) {
|
||||
const proto = convertApiConfigurationToProto({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
|
||||
export interface OAuthCredentials {
|
||||
@@ -12,6 +13,28 @@ export interface StartSessionResult {
|
||||
|
||||
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
|
||||
|
||||
export type GlobalCompactionStrategy = "basic" | "agentic"
|
||||
|
||||
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "", "utf8"))
|
||||
return settings.compactionStrategy === "agentic" ? "agentic" : "basic"
|
||||
} catch {
|
||||
return "basic"
|
||||
}
|
||||
}
|
||||
|
||||
export function setCompactionStrategyGlobally(compactionStrategy: GlobalCompactionStrategy): void {
|
||||
const filePath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
if (filePath) {
|
||||
let settings = {}
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(filePath, "utf8"))
|
||||
} catch {}
|
||||
writeFileSync(filePath, JSON.stringify({ ...settings, compactionStrategy }))
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateCommandOutput(output: string): string {
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
"src/shared/vsCodeSelectorUtils.test.ts",
|
||||
"src/shared/proto-conversions/models/**/*.test.ts",
|
||||
"src/core/storage/remote-config/**/*.test.ts",
|
||||
"src/core/controller/state/**/*.test.ts",
|
||||
"src/core/controller/slash/**/*.test.ts",
|
||||
"src/services/mcp/__tests__/settingsLock.test.ts",
|
||||
"src/shared/model-catalog/provider-helpers.test.ts",
|
||||
|
||||
+44
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
const newTask = vi.fn().mockResolvedValue(undefined)
|
||||
const askResponse = vi.fn().mockResolvedValue(undefined)
|
||||
const condense = vi.fn().mockResolvedValue(undefined)
|
||||
const trackIntent = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock("@/services/grpc-client", () => ({
|
||||
TaskServiceClient: {
|
||||
@@ -18,6 +19,9 @@ vi.mock("@/services/grpc-client", () => ({
|
||||
condense: (req: unknown) => condense(req),
|
||||
reportBug: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
UiServiceClient: {
|
||||
trackIntent: (req: unknown) => trackIntent(req),
|
||||
},
|
||||
}))
|
||||
|
||||
// Proto request factories just echo their input so we can assert on it.
|
||||
@@ -25,6 +29,9 @@ vi.mock("@shared/proto/cline/task", () => ({
|
||||
AskResponseRequest: { create: (x: unknown) => x },
|
||||
NewTaskRequest: { create: (x: unknown) => x },
|
||||
}))
|
||||
vi.mock("@shared/proto/cline/ui", () => ({
|
||||
IntentEvent: { create: (x: unknown) => x },
|
||||
}))
|
||||
vi.mock("@shared/proto/cline/common", () => ({
|
||||
EmptyRequest: { create: (x: unknown) => x },
|
||||
StringRequest: { create: (x: unknown) => x },
|
||||
@@ -93,6 +100,8 @@ describe("useMessageHandlers — send routing", () => {
|
||||
askResponse.mockResolvedValue(undefined)
|
||||
condense.mockReset()
|
||||
condense.mockResolvedValue(undefined)
|
||||
trackIntent.mockReset()
|
||||
trackIntent.mockResolvedValue(undefined)
|
||||
mockTurnState = undefined
|
||||
})
|
||||
|
||||
@@ -108,6 +117,7 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(condense).toHaveBeenCalledWith(expect.objectContaining({ value: "compact" }))
|
||||
expect(newTask).not.toHaveBeenCalled()
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
expect(trackIntent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("routes the /smol alias to the condense RPC as well", async () => {
|
||||
@@ -121,6 +131,7 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(condense).toHaveBeenCalledTimes(1)
|
||||
expect(newTask).not.toHaveBeenCalled()
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
expect(trackIntent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not intercept /compact when there is no active task (starts a new task instead)", async () => {
|
||||
@@ -133,6 +144,17 @@ describe("useMessageHandlers — send routing", () => {
|
||||
|
||||
expect(condense).not.toHaveBeenCalled()
|
||||
expect(newTask).toHaveBeenCalledTimes(1)
|
||||
expect(trackIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: true,
|
||||
hasImages: false,
|
||||
hasFiles: false,
|
||||
hasActiveTask: false,
|
||||
textLength: "/compact".length,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("after a completed turn (no clineAsk), Enter continues the conversation via askResponse — NOT newTask", async () => {
|
||||
@@ -148,6 +170,17 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(askResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ responseType: "messageResponse", text: "another question" }),
|
||||
)
|
||||
expect(trackIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: true,
|
||||
hasImages: false,
|
||||
hasFiles: false,
|
||||
hasActiveTask: true,
|
||||
textLength: "another question".length,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("shows pending composer state before a follow-up askResponse resolves", async () => {
|
||||
@@ -379,6 +412,17 @@ describe("useMessageHandlers — send routing", () => {
|
||||
|
||||
expect(newTask).toHaveBeenCalledTimes(1)
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
expect(trackIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: true,
|
||||
hasImages: false,
|
||||
hasFiles: false,
|
||||
hasActiveTask: false,
|
||||
textLength: "brand new task".length,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("restores pending new-task UI state when the RPC fails", async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/cline/task"
|
||||
import { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
|
||||
import { SlashServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import type { ButtonActionType } from "../shared/buttonConfig"
|
||||
import type { ChatState, MessageHandlers } from "../types/chatTypes"
|
||||
|
||||
@@ -64,6 +65,19 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
if (hasContent) {
|
||||
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
|
||||
let messageSent = false
|
||||
const trackPromptSubmitted = (hasActiveTask: boolean) => {
|
||||
UiServiceClient.trackIntent(
|
||||
IntentEvent.create({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: messageToSend.length > 0,
|
||||
hasImages: images.length > 0,
|
||||
hasFiles: files.length > 0,
|
||||
hasActiveTask,
|
||||
textLength: messageToSend.length,
|
||||
}),
|
||||
).catch((error) => console.error("Failed to track prompt submit:", error))
|
||||
}
|
||||
const clearSentMessageState = () => {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
@@ -84,6 +98,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
request: ReturnType<typeof AskResponseRequest.create>,
|
||||
options: { showPendingMessage?: boolean } = {},
|
||||
) => {
|
||||
trackPromptSubmitted(true)
|
||||
clearSentMessageState()
|
||||
if (options.showPendingMessage) {
|
||||
const afterTs = Math.max(0, ...messages.map((message) => message.ts))
|
||||
@@ -118,6 +133,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
files,
|
||||
})
|
||||
clearSentMessageState()
|
||||
trackPromptSubmitted(false)
|
||||
try {
|
||||
await TaskServiceClient.newTask(request)
|
||||
} catch (error) {
|
||||
@@ -256,9 +272,16 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
|
||||
// Start a new task
|
||||
const startNewTask = useCallback(async () => {
|
||||
UiServiceClient.trackIntent(
|
||||
IntentEvent.create({
|
||||
action: "new_task_clicked",
|
||||
source: "chat_new_task",
|
||||
hasActiveTask: messages.length > 0,
|
||||
}),
|
||||
).catch((error) => console.error("Failed to track new task click:", error))
|
||||
setActiveQuote(null)
|
||||
await TaskServiceClient.clearTask(EmptyRequest.create({}))
|
||||
}, [setActiveQuote])
|
||||
}, [messages.length, setActiveQuote])
|
||||
|
||||
// Clear input state helper
|
||||
const clearInputState = useCallback(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { HistoryIcon, PlusIcon, PuzzleIcon, SettingsIcon, UserCircleIcon } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
|
||||
export const Navbar = () => {
|
||||
@@ -17,6 +18,12 @@ export const Navbar = () => {
|
||||
tooltip: "New Task",
|
||||
icon: PlusIcon,
|
||||
navigate: () => {
|
||||
UiServiceClient.trackIntent(
|
||||
IntentEvent.create({
|
||||
action: "new_task_clicked",
|
||||
source: "navbar",
|
||||
}),
|
||||
).catch((error) => console.error("Failed to track new task click:", error))
|
||||
// Close the current task, then navigate to the chat view
|
||||
TaskServiceClient.clearTask({})
|
||||
.catch((error) => {
|
||||
|
||||
+1
@@ -69,6 +69,7 @@ describe("providerSettingsRegistry", () => {
|
||||
["nousResearch", "NousResearch", undefined],
|
||||
["poolside", "Poolside", undefined],
|
||||
["sambanova", "SambaNova", "https://docs.sambanova.ai/cloud/docs/get-started/overview"],
|
||||
["tencent-tokenhub", "Tencent TokenHub", "https://cloud.tencent.com/document/product/1823/130050"],
|
||||
["vercel-ai-gateway", "Vercel AI Gateway", "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai"],
|
||||
["v0", "Vercel v0", undefined],
|
||||
["wandb", "W&B", "https://wandb.ai"],
|
||||
|
||||
@@ -97,6 +97,9 @@ const GENERIC_PROVIDER_PRESENTATION_OVERRIDES: Record<string, GenericProviderPre
|
||||
signupUrl: "https://wandb.ai",
|
||||
},
|
||||
xiaomi: {},
|
||||
"tencent-tokenhub": {
|
||||
signupUrl: "https://cloud.tencent.com/document/product/1823/130050",
|
||||
},
|
||||
"zai-coding-plan": {},
|
||||
}
|
||||
|
||||
@@ -157,6 +160,7 @@ const FALLBACK_GENERIC_PROVIDER_NAMES = {
|
||||
v0: "Vercel v0",
|
||||
wandb: "W&B",
|
||||
xiaomi: "Xiaomi",
|
||||
"tencent-tokenhub": "Tencent TokenHub",
|
||||
"zai-coding-plan": "Z.AI Coding Plan",
|
||||
} as const
|
||||
|
||||
|
||||
+34
-5
@@ -1,23 +1,27 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import FeatureSettingsSection from "./FeatureSettingsSection"
|
||||
|
||||
const mockUpdateSetting = vi.fn()
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => ({
|
||||
const mockExtensionState = vi.hoisted(() => ({
|
||||
value: {
|
||||
enableCheckpointsSetting: true,
|
||||
hooksEnabled: false,
|
||||
showFeatureTips: false,
|
||||
mcpDisplayMode: "rich",
|
||||
yoloModeToggled: false,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: true },
|
||||
focusChainSettings: { enabled: false, remindClineInterval: 6 },
|
||||
remoteConfigSettings: {},
|
||||
backgroundEditEnabled: false,
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => mockExtensionState.value),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/settingsHandlers", () => ({
|
||||
@@ -25,6 +29,15 @@ vi.mock("../utils/settingsHandlers", () => ({
|
||||
}))
|
||||
|
||||
describe("FeatureSettingsSection", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateSetting.mockClear()
|
||||
mockExtensionState.value = {
|
||||
...mockExtensionState.value,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
}
|
||||
})
|
||||
|
||||
it("renders Hooks feature toggle", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
@@ -49,6 +62,22 @@ describe("FeatureSettingsSection", () => {
|
||||
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("renders the Auto Compact Strategy setting in the Agent section", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
expect(screen.getByText("Auto Compact Strategy")).toBeTruthy()
|
||||
|
||||
const agentSection = container.querySelector("#agent-features")
|
||||
expect(agentSection?.textContent).toContain("Basic")
|
||||
})
|
||||
|
||||
it("disables Auto Compact Strategy when Auto Compact is off", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
const strategySelect = container.querySelector("#agent-features button[role='combobox']")
|
||||
expect(strategySelect).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("calls updateSetting with hooksEnabled when toggled", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
mcpDisplayMode,
|
||||
yoloModeToggled,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
subagentsEnabled,
|
||||
worktreesEnabled,
|
||||
remoteConfigSettings,
|
||||
@@ -201,6 +202,22 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
onChange={(checked) => updateSetting(feature.settingKey, checked)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2 py-3">
|
||||
<Label className="text-sm font-medium text-foreground">Auto Compact Strategy</Label>
|
||||
<p className="text-xs text-muted-foreground">Controls how auto compaction rewrites context.</p>
|
||||
<Select
|
||||
disabled={!useAutoCondense}
|
||||
onValueChange={(value) => updateSetting("compactionStrategy", value)}
|
||||
value={compactionStrategy ?? "basic"}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">Basic</SelectItem>
|
||||
<SelectItem value="agentic">Agentic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
yoloModeToggled: false,
|
||||
customPrompt: undefined,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
favoritedModelIds: [],
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
@@ -15,10 +15,10 @@ Cline is an AI coding agent that lives in your editor and your terminal. It can
|
||||
Choose the model access path that fits your workflow:
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Cline Provider" icon="bolt" href="/getting-started/cline-provider">
|
||||
<Card title="Cline (usage-billing)" icon="bolt" href="/getting-started/cline-provider">
|
||||
Fastest setup path with one sign-in, built-in billing, and free model options.
|
||||
</Card>
|
||||
<Card title="ClinePass (beta)" icon="credit-card" href="/getting-started/clinepass">
|
||||
<Card title="ClinePass" icon="credit-card" href="/getting-started/clinepass">
|
||||
Flat $9.99/month subscription for selected open coding models with 2-5x API rate limits.
|
||||
</Card>
|
||||
<Card title="Bring Your Own Key" icon="key" href="/getting-started/authorizing-with-cline#byok-cloud--local">
|
||||
|
||||
@@ -92,12 +92,12 @@ You pay for AI usage when using cloud providers like Anthropic, OpenAI, OpenRout
|
||||
| Provider Type | Billing Model |
|
||||
|--------------|---------------|
|
||||
| **Cline Provider** | Pay-per-use with credits you purchase |
|
||||
| **ClinePass (beta)** | Flat monthly subscription for selected open coding models, with usage measured against your ClinePass (beta) quota |
|
||||
| **ClinePass** | Flat monthly subscription for selected open coding models, with usage measured against your ClinePass quota |
|
||||
| **Direct API keys** | Billed by your provider (Anthropic, OpenAI, etc.) |
|
||||
| **OpenRouter/Requesty** | Aggregated billing across multiple models |
|
||||
| **Local models** | Free (you provide the hardware) |
|
||||
|
||||
If you want predictable monthly spend instead of per-request charges, [ClinePass (beta)](/getting-started/clinepass) provides selected open coding models for $9.99/month. If you're using your own API keys, check your provider's pricing page for current rates. Prices change frequently and vary by model.
|
||||
If you want predictable monthly spend instead of per-request charges, [ClinePass](/getting-started/clinepass) provides selected open coding models for $9.99/month. If you're using your own API keys, check your provider's pricing page for current rates. Prices change frequently and vary by model.
|
||||
|
||||
### Free Options
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ description: "Authenticate with Cline and choose your first AI model"
|
||||
|
||||
Cline connects to AI models through a **provider**. You have three common paths:
|
||||
|
||||
- **Cline Provider** (recommended): sign in with Google/GitHub/email, no API key setup.
|
||||
- **ClinePass (beta)**: a $9.99/month subscription provider with access to selected open coding models and 2-5x API rate limits.
|
||||
- **Cline (usage-billing)** (recommended): sign in with Google/GitHub/email, no API key setup.
|
||||
- **ClinePass**: a $9.99/month subscription provider with access to selected open coding models and 2-5x API rate limits.
|
||||
- **Bring Your Own Key (BYOK)**: use your own provider credentials (cloud or local runtimes).
|
||||
|
||||
## Menu
|
||||
@@ -26,8 +26,8 @@ Cline connects to AI models through a **provider**. You have three common paths:
|
||||
</Step>
|
||||
|
||||
<Step title="Authenticate">
|
||||
- **Cline Provider:** Click **Sign In** and complete OAuth.
|
||||
- **ClinePass (beta):** Select **ClinePass (beta)**, click **Sign In**, and subscribe if prompted.
|
||||
- **Cline (usage-billing):** Click **Sign In** and complete OAuth.
|
||||
- **ClinePass:** Select **ClinePass**, click **Sign In**, and subscribe if prompted.
|
||||
- **BYOK cloud provider:** Paste your API key into the **API Key** field.
|
||||
- **Local runtime (Ollama/LM Studio):** no key needed; ensure runtime is running.
|
||||
</Step>
|
||||
@@ -39,7 +39,7 @@ Cline connects to AI models through a **provider**. You have three common paths:
|
||||
|
||||
## Provider Options
|
||||
|
||||
### Cline Provider
|
||||
### Cline (usage-billing)
|
||||
|
||||
- One sign-in, no key management
|
||||
- Built-in billing and free model options
|
||||
@@ -47,13 +47,13 @@ Cline connects to AI models through a **provider**. You have three common paths:
|
||||
|
||||
Add credits in Cline settings or at [app.cline.bot/dashboard](https://app.cline.bot/dashboard).
|
||||
|
||||
### ClinePass (beta)
|
||||
### ClinePass
|
||||
|
||||
- Flat **$9.99/month** subscription
|
||||
- 2-5x API rate limits for longer agent sessions
|
||||
- Curated open coding models including GLM, Kimi, DeepSeek, and MiMo
|
||||
|
||||
Subscribe from the [ClinePass (beta) dashboard](https://app.cline.bot/dashboard/subscription), then select **ClinePass (beta)** as your provider in Cline. See the [ClinePass (beta) guide](/getting-started/clinepass) for included models and reference pricing.
|
||||
Subscribe from the [ClinePass dashboard](https://app.cline.bot/dashboard/subscription?personal=true), then select **ClinePass** as your provider in Cline. See the [ClinePass guide](/getting-started/clinepass) for included models and reference pricing.
|
||||
|
||||
### BYOK (cloud + local)
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
title: "Cline Provider"
|
||||
description: "Use the Cline Provider for pay-as-you-go model access with built-in authentication and Cline credits."
|
||||
title: "Cline (usage-billing)"
|
||||
description: "Use Cline (usage-billing) for pay-as-you-go model access with built-in authentication and Cline credits."
|
||||
---
|
||||
|
||||
The **Cline Provider** is the simplest way to get started with pay-as-you-go model access in Cline.
|
||||
Cline (usage-billing) is the simplest way to get started with pay-as-you-go model access in Cline.
|
||||
|
||||
Instead of managing separate API keys across multiple vendors, you sign in once, add **Cline credits**, and select from available models directly in Cline.
|
||||
|
||||
If you want a flat monthly subscription for selected open coding models with higher rate limits, use [ClinePass (beta)](/getting-started/clinepass) instead. ClinePass (beta) is a separate provider in Cline.
|
||||
If you want a flat monthly subscription for selected open coding models with higher rate limits, use [ClinePass](/getting-started/clinepass) instead. ClinePass is a separate provider in Cline.
|
||||
|
||||
## Why use Cline Provider
|
||||
## Why use Cline (usage-billing)
|
||||
|
||||
- **Fastest setup**: no manual API key copy/paste
|
||||
- **Fastest setup**: no manual API key copy/paste; access to 100+ models supported by Cline
|
||||
- **One account**: sign in once with Google, GitHub, or email
|
||||
- **Pay as you go**: add Cline credits and use one balance across supported models
|
||||
- **Free options**: look for models tagged **FREE** in the selector
|
||||
|
||||
## How it works
|
||||
|
||||
The Cline Provider is the pay-as-you-go provider in Cline. Add Cline credits, then select **Cline** wherever you configure your provider.
|
||||
Cline (usage-billing) is the pay-as-you-go provider in Cline. Add Cline credits, then select **Cline** wherever you configure your provider.
|
||||
|
||||
- **IDE Extension**: Go to IDE extension settings, set **API Provider** to **Cline**, and sign in.
|
||||
- **CLI**: Go to `/settings` and select **Cline** as your provider.
|
||||
@@ -26,13 +26,13 @@ The Cline Provider is the pay-as-you-go provider in Cline. Add Cline credits, th
|
||||
## Credits and usage
|
||||
|
||||
- Add **Cline credits** from your [Cline dashboard](https://app.cline.bot/dashboard)
|
||||
- Credits are used on a pay-as-you-go basis when you select paid models through the Cline Provider
|
||||
- Credits are used on a pay-as-you-go basis when you select paid models through Cline (usage-billing)
|
||||
- View usage in Cline Settings → **View Usage**
|
||||
- Switch organizations in Cline Settings → **Switch Organization**
|
||||
|
||||
## Related
|
||||
|
||||
- [Authorization](/getting-started/authorizing-with-cline)
|
||||
- [ClinePass (beta)](/getting-started/clinepass)
|
||||
- [ClinePass](/getting-started/clinepass)
|
||||
- [Local models](/running-models-locally/overview)
|
||||
- [Provider setup guides](/provider-config/openrouter)
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
---
|
||||
title: "ClinePass (beta)"
|
||||
title: "ClinePass"
|
||||
description: "A low-cost monthly subscription that gives you reliable access to popular open coding models with 2-5x API rate limits."
|
||||
---
|
||||
|
||||
<Warning>
|
||||
ClinePass is currently under invite-only private preview.
|
||||
</Warning>
|
||||
|
||||
ClinePass is a low-cost monthly subscription — **$9.99/month** — that gives you reliable access to popular open coding models with **2-5x the API rate limits** of standard access.
|
||||
|
||||
It's completely optional. You don't need ClinePass to use Cline, and you can always use any other provider alongside it.
|
||||
|
||||
<Card title="Subscribe to ClinePass" icon="credit-card" href="https://app.cline.bot/dashboard/subscription">
|
||||
<Card title="Subscribe to ClinePass" icon="credit-card" href="https://app.cline.bot/dashboard/subscription?personal=true">
|
||||
Get started for $9.99/month and unlock 2-5x API rate limits.
|
||||
</Card>
|
||||
|
||||
## How it works
|
||||
|
||||
ClinePass is a separate provider in Cline. After subscribing, select **ClinePass** wherever you configure your provider.
|
||||
|
||||
- **IDE Extension**: Go to IDE extension settings, set **API Provider** to **ClinePass**, and sign in.
|
||||
|
||||
<Frame>
|
||||
<img src="/assets/clinepass/clinepass-ide-extension.png" alt="Cline IDE extension settings with ClinePass selected as the API Provider" />
|
||||
</Frame>
|
||||
|
||||
- **CLI**: Go to `/settings` and select **ClinePass** as your provider.
|
||||
|
||||
<Frame>
|
||||
<img src="/assets/clinepass/clinepass-cli.png" alt="Cline CLI settings showing ClinePass selected as the provider" />
|
||||
</Frame>
|
||||
|
||||
## Why ClinePass
|
||||
|
||||
Open models have gotten really good. They now reach performance close to proprietary models for coding tasks, and because many providers can serve them competitively, they're usually far cheaper.
|
||||
@@ -27,15 +39,8 @@ ClinePass solves this by:
|
||||
- Offering **2-5x the API rate limits** so you can run long, complex agent tasks without interruption
|
||||
- Providing stable access through Cline's infrastructure
|
||||
|
||||
## How it works
|
||||
|
||||
ClinePass is a separate provider in Cline. After subscribing, select **ClinePass** wherever you configure your provider.
|
||||
|
||||
- **IDE Extension**: Go to IDE extension settings, set **API Provider** to **ClinePass**, and sign in.
|
||||
- **CLI**: Go to `/settings` and select **ClinePass** as your provider.
|
||||
|
||||
<Note>
|
||||
ClinePass is a separate provider from the Cline Provider. You can use both independently — subscribe to ClinePass for 2-5x API rate limits, or use the Cline Provider for pay-as-you-go access.
|
||||
ClinePass is a separate provider from Cline (usage-billing). You can use both independently — subscribe to ClinePass for 2-5x API rate limits, or use Cline (usage-billing) for pay-as-you-go access.
|
||||
</Note>
|
||||
|
||||
## Models
|
||||
@@ -43,16 +48,43 @@ ClinePass is a separate provider from the Cline Provider. You can use both indep
|
||||
ClinePass includes the following models, tested and benchmarked for coding agent use:
|
||||
|
||||
| Model | Model ID |
|
||||
|-------|----------|
|
||||
| GLM-5.2 | `glm-5.2` |
|
||||
| Kimi K2.7 Code | `kimi-k2.7-code` |
|
||||
| Kimi K2.6 | `kimi-k2.6` |
|
||||
| DeepSeek V4 Pro | `deepseek-v4-pro` |
|
||||
| DeepSeek V4 Flash | `deepseek-v4-flash` |
|
||||
| MiMo-V2.5 | `mimo-v2.5` |
|
||||
| MiMo-V2.5-Pro | `mimo-v2.5-pro` |
|
||||
|-------|------------|
|
||||
| GLM-5.2 | `cline-pass/glm-5.2` |
|
||||
| Kimi K2.7 Code | `cline-pass/kimi-k2.7-code` |
|
||||
| Kimi K2.6 | `cline-pass/kimi-k2.6` |
|
||||
| DeepSeek V4 Pro | `cline-pass/deepseek-v4-pro` |
|
||||
| DeepSeek V4 Flash | `cline-pass/deepseek-v4-flash` |
|
||||
| MiMo-V2.5 | `cline-pass/mimo-v2.5` |
|
||||
| MiMo-V2.5-Pro | `cline-pass/mimo-v2.5-pro` |
|
||||
| MiniMax M3 | `cline-pass/minimax-m3` |
|
||||
| Qwen3.7 Max | `cline-pass/qwen3.7-max` |
|
||||
| Qwen3.7 Plus | `cline-pass/qwen3.7-plus` |
|
||||
|
||||
### Reference pricing
|
||||
## Using ClinePass outside of Cline
|
||||
|
||||
You can use ClinePass models from your own scripts, apps, or automation through the Cline API. The API uses the same OpenAI-compatible Chat Completions format as the rest of Cline's API.
|
||||
|
||||
To get started, create an API key from **Settings > API Keys** in [app.cline.bot](https://app.cline.bot). For the complete walkthrough, see the [Cline API Getting Started guide](/api/getting-started).
|
||||
|
||||
Use the full ClinePass model slug in the `model` field:
|
||||
|
||||
```bash
|
||||
export CLINE_API_KEY="your_api_key_here"
|
||||
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $CLINE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "cline-pass/qwen3.7-max",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Write a TypeScript function that validates an email address."}
|
||||
],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
## Reference pricing
|
||||
|
||||
ClinePass is a flat monthly subscription, so you are not charged the individual API prices below. These reference prices show the underlying per-1M-token rates for each model and can help you understand how usage is measured against your ClinePass quota (2-5x quota compares to paying standard api rate).
|
||||
|
||||
@@ -65,19 +97,23 @@ ClinePass is a flat monthly subscription, so you are not charged the individual
|
||||
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - |
|
||||
| MiMo-V2.5 | $0.14 | $0.28 | $0.0028 | - |
|
||||
| MiMo-V2.5-Pro | $1.74 | $3.48 | $0.0145 | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 |
|
||||
| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 |
|
||||
|
||||
## Goals
|
||||
## Usage
|
||||
|
||||
We created ClinePass to:
|
||||
ClinePass measures usage against three limits:
|
||||
|
||||
- Make AI coding accessible to more people with a low-cost subscription
|
||||
- Provide reliable access to the best open coding models
|
||||
- Curate models that are tested and benchmarked for coding agent use
|
||||
- Remove rate-limit friction with **2-5x API rate limits**
|
||||
- Have no lock-in — you can use any other provider with Cline as well
|
||||
- **5-hour rolling window** — your usage within rolling 5-hour period
|
||||
- **Weekly** — your usage over the calendar week
|
||||
- **Monthly** — your usage over the calendar month
|
||||
|
||||
To check your current usage, visit your [Cline dashboard](https://app.cline.bot/dashboard/subscription?personal=true).
|
||||
|
||||
## Related
|
||||
|
||||
- [Cline Provider](/getting-started/cline-provider)
|
||||
- [Cline (usage-billing)](/getting-started/cline-provider)
|
||||
- [Authorization](/getting-started/authorizing-with-cline)
|
||||
- [Local models](/running-models-locally/overview)
|
||||
|
||||
@@ -30,7 +30,7 @@ Use this if you want Cline inside your editor UI.
|
||||
Use the Cline activity bar icon, or run `Cline: Open In New Tab` from Command Palette.
|
||||
</Step>
|
||||
<Step title="Authorize with Cline">
|
||||
After installing the extension, complete provider setup in Cline settings. Use the Cline Provider for pay-as-you-go access, ClinePass (beta) for a flat monthly subscription, or bring your own provider key.
|
||||
After installing the extension, complete provider setup in Cline settings. Use the Cline Provider for pay-as-you-go access, ClinePass for a flat monthly subscription, or bring your own provider key.
|
||||
|
||||
[Authorize with Cline](/getting-started/authorizing-with-cline)
|
||||
</Step>
|
||||
@@ -53,7 +53,7 @@ Use this if you want Cline inside your editor UI.
|
||||
**View** → **Tool Windows** → **Cline**.
|
||||
</Step>
|
||||
<Step title="Authorize with Cline">
|
||||
After installing the extension, complete provider setup in Cline settings. Use the Cline Provider for pay-as-you-go access, ClinePass (beta) for a flat monthly subscription, or bring your own provider key.
|
||||
After installing the extension, complete provider setup in Cline settings. Use the Cline Provider for pay-as-you-go access, ClinePass for a flat monthly subscription, or bring your own provider key.
|
||||
|
||||
[Authorize with Cline](/getting-started/authorizing-with-cline)
|
||||
</Step>
|
||||
|
||||
@@ -7,7 +7,7 @@ description: "Use Cline CLI for interactive terminal sessions and automated head
|
||||
## Prerequisites
|
||||
|
||||
- Cline CLI installed (install via `npm i -g cline`)
|
||||
- Provider authenticated (`cline auth`) — use the Cline Provider, ClinePass (beta), or your own provider key ([Authorization Guide](/getting-started/authorizing-with-cline#cli-setup))
|
||||
- Provider authenticated (`cline auth`) — use the Cline Provider, ClinePass, or your own provider key ([Authorization Guide](/getting-started/authorizing-with-cline#cli-setup))
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.55
|
||||
|
||||
- Add Tencent TokenHub as a provider
|
||||
- Add a compaction strategy setting so you can choose how context compaction works
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3), where a shallow session could auto-compact immediately and reduce the initial task to just the input wrapper
|
||||
- Use a curated default when migrating legacy provider settings
|
||||
- Advertise run commands as shell strings
|
||||
- Refresh the bundled model catalog with the latest provider models
|
||||
|
||||
## 0.0.54
|
||||
|
||||
- Improve basic compaction token budgeting so context compaction is more accurate
|
||||
- Preserve error detail and fetch error cause information so failures surface clearer messages
|
||||
- Preserve failed run error messages instead of dropping them
|
||||
- Derive model info in the provider/model runtime path for more reliable provider/model handling
|
||||
- Add ClinePass subscription support to the account service
|
||||
|
||||
## 0.0.53
|
||||
|
||||
- Show when request cost is covered by the user's Cline subscription
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.53",
|
||||
"version": "0.0.55",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.53",
|
||||
"version": "0.0.55",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -153,6 +153,38 @@ describe("ClineAccountService", () => {
|
||||
expect(plans).toEqual(plansPayload);
|
||||
});
|
||||
|
||||
it("fetches the current user's subscription plan", async () => {
|
||||
const planPayload = {
|
||||
plan: {
|
||||
id: "plan-1",
|
||||
displayName: "ClinePass Monthly",
|
||||
interval: "Monthly",
|
||||
},
|
||||
planHistoryId: "history-1",
|
||||
subscriptionId: "sub-1",
|
||||
userId: "user-1",
|
||||
};
|
||||
const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
expect(String(input)).toBe("https://api.cline.bot/api/v1/users/me/plan");
|
||||
expect(init?.headers).toMatchObject({
|
||||
Authorization: "Bearer workos:token-123",
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, data: planPayload }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
const service = new ClineAccountService({
|
||||
apiBaseUrl: "https://api.cline.bot",
|
||||
getAuthToken: async () => "workos:token-123",
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const plan = await service.fetchCurrentUserPlan();
|
||||
expect(plan).toEqual(planPayload);
|
||||
});
|
||||
|
||||
it("fetches remote config with fallback org selected", async () => {
|
||||
const remoteConfigPayload = {
|
||||
organizationId: "org-fallback",
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ClineOrganization,
|
||||
ClineSubscriptionPlan,
|
||||
FeaturebaseTokenResponse,
|
||||
UserCurrentPlan,
|
||||
UserRemoteConfigResponse,
|
||||
} from "./types";
|
||||
|
||||
@@ -156,6 +157,10 @@ export class ClineAccountService {
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchCurrentUserPlan(): Promise<UserCurrentPlan | undefined> {
|
||||
return this.request<UserCurrentPlan | undefined>("/api/v1/users/me/plan");
|
||||
}
|
||||
|
||||
public async fetchOrganization(
|
||||
organizationId: string,
|
||||
): Promise<ClineOrganization> {
|
||||
@@ -334,9 +339,7 @@ export class ClineAccountService {
|
||||
if (!envelope.success) {
|
||||
throw new Error(envelope.error || "Cline account request failed");
|
||||
}
|
||||
if (envelope.data !== undefined) {
|
||||
return envelope.data;
|
||||
}
|
||||
return envelope.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user