mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25ef0939cc | |||
| 4f770011e9 | |||
| a1d69fee6f | |||
| 3575b38122 | |||
| b5468e1227 | |||
| 10d1c41b7a | |||
| b823358867 | |||
| b876945c6d | |||
| 453cdea040 | |||
| 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 | |||
| 263b58f8c3 | |||
| b193a81ac9 | |||
| 92806c60ca | |||
| 3a05171e30 |
@@ -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,49 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.37
|
||||
|
||||
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
|
||||
- Plan/act mode switches are now visible to the model, so it knows when you change modes mid-session
|
||||
- Fixed plan/act mode notices being dropped from prompts sent to the model
|
||||
- Fixed a race where switching modes in an empty session could trigger an unexpected restart
|
||||
|
||||
## 3.0.36
|
||||
|
||||
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
|
||||
|
||||
## 3.0.35
|
||||
|
||||
- ClinePass is now enabled for all CLI users
|
||||
- Recover missing interactive sessions when reading messages
|
||||
- Format structured commands in history export
|
||||
- Add the subscription promo code when linking to the dashboard subscription page
|
||||
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
|
||||
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
|
||||
- Advertise run commands as shell strings (from SDK v0.0.55)
|
||||
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
|
||||
|
||||
## 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.37",
|
||||
"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();
|
||||
|
||||
@@ -2,7 +2,15 @@ import { createTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import { applyInteractiveModeConfig } from "./mode";
|
||||
import {
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
type AppliedModeChange,
|
||||
applyInteractiveModeConfig,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./mode";
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
|
||||
@@ -40,6 +48,190 @@ const switchToActModeTool = createTool({
|
||||
execute: async () => "ok",
|
||||
});
|
||||
|
||||
describe("createInteractiveModeSwitchTool", () => {
|
||||
function makeSwitchTool(config: Config) {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
} = { current: vi.fn() };
|
||||
const tool = createInteractiveModeSwitchTool({
|
||||
config,
|
||||
pendingModeChange,
|
||||
tuiModeChanged,
|
||||
});
|
||||
return { tool, pendingModeChange, tuiModeChanged };
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
agentId: "agent-1",
|
||||
iteration: 0,
|
||||
} as const;
|
||||
|
||||
it("completes the run so the model never continues with plan-mode tools", () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool } = makeSwitchTool(config);
|
||||
|
||||
// The act-mode tool set only exists after the session rebuild, which
|
||||
// happens between runs; without completesRun the model keeps working
|
||||
// with stale plan-mode tools after being told the switch succeeded.
|
||||
expect(tool.lifecycle?.completesRun).toBe(true);
|
||||
});
|
||||
|
||||
it("queues a tool-sourced mode change and notifies the TUI", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool, pendingModeChange, tuiModeChanged } = makeSwitchTool(config);
|
||||
|
||||
const result = await tool.execute({}, toolContext);
|
||||
|
||||
expect(pendingModeChange).toEqual({ current: "act", source: "tool" });
|
||||
expect(tuiModeChanged.current).toHaveBeenCalledWith("act");
|
||||
expect(result).toContain("successfully switched to act mode");
|
||||
});
|
||||
|
||||
it("errors instead of completing the run when already in act mode", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "act";
|
||||
const { tool, pendingModeChange } = makeSwitchTool(config);
|
||||
|
||||
// A successful result would end the run via completesRun even though
|
||||
// nothing changed, so the no-op case must surface as a tool error.
|
||||
await expect(tool.execute({}, toolContext)).rejects.toThrow(
|
||||
"Already in act mode.",
|
||||
);
|
||||
expect(pendingModeChange.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTurnWithActModeContinuation", () => {
|
||||
type TurnResult = { finishReason: string; iterations: number };
|
||||
|
||||
function makeHarness(input: {
|
||||
initial: TurnResult | undefined;
|
||||
continuation?: TurnResult | undefined;
|
||||
modeChanges: Array<AppliedModeChange | undefined>;
|
||||
}) {
|
||||
const applied = [...input.modeChanges];
|
||||
const sendContinuationTurn = vi.fn(async () => input.continuation);
|
||||
return {
|
||||
sendContinuationTurn,
|
||||
run: () =>
|
||||
sendTurnWithActModeContinuation<TurnResult>({
|
||||
sendInitialTurn: async () => input.initial,
|
||||
sendContinuationTurn,
|
||||
applyPendingModeChange: async () => applied.shift(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("continues the plan after a tool-initiated switch completes the run", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: { finishReason: "completed", iterations: 3 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).toHaveBeenCalledWith(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 5 });
|
||||
});
|
||||
|
||||
it("does not continue after a UI-initiated mode change", async () => {
|
||||
// A Tab toggle can race a natural turn completion; a "ui" source must
|
||||
// never start executing a plan the user did not approve.
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [{ mode: "act", source: "ui" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("does not continue when the switch turn was aborted", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "aborted", iterations: 1 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "aborted", iterations: 1 });
|
||||
});
|
||||
|
||||
it("does not continue when no mode change was pending", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("returns the switch turn result when the continuation yields nothing", async () => {
|
||||
const { run } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: undefined,
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModeSwitchNoticeTracker", () => {
|
||||
it("records a switch and clears it on consume", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels a round trip that returns to the mode the model last saw", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the original starting mode across chained switches", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
});
|
||||
|
||||
it("ignores a no-op switch", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("plan", "plan");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModeConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveSystemPrompt).mockClear();
|
||||
|
||||
@@ -2,17 +2,42 @@ import { createTool } from "@cline/shared";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
|
||||
type InteractiveUiMode = "plan" | "act";
|
||||
export type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
/**
|
||||
* Pending mode change plus who requested it. The switch_to_act_mode tool and
|
||||
* the TUI mode toggle share this slot, but only a tool-initiated switch means
|
||||
* "the user approved the plan" -- a UI toggle that lands as a turn finishes
|
||||
* must not trigger plan execution.
|
||||
*/
|
||||
export type PendingModeChange = {
|
||||
current: InteractiveUiMode | null;
|
||||
source: "tool" | "ui" | null;
|
||||
};
|
||||
|
||||
export type AppliedModeChange = {
|
||||
mode: InteractiveUiMode;
|
||||
source: "tool" | "ui";
|
||||
};
|
||||
|
||||
/**
|
||||
* Canned prompt that drives the auto-continue turn after the model calls
|
||||
* switch_to_act_mode. It is a synthetic user message, so transcript hydration
|
||||
* filters it out of the chat display.
|
||||
*/
|
||||
export const ACT_MODE_CONTINUATION_PROMPT =
|
||||
"The user approved switching to act mode. Continue with the approved plan now.";
|
||||
|
||||
export function createInteractiveModeSwitchTool(input: {
|
||||
config: Config;
|
||||
pendingModeChange: { current: InteractiveUiMode | null };
|
||||
pendingModeChange: PendingModeChange;
|
||||
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
|
||||
}) {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Call this after the user has confirmed they want to proceed with the plan. Do not call this proactively or before the user has agreed.",
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -20,17 +45,101 @@ export function createInteractiveModeSwitchTool(input: {
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// The act-mode tools only exist after the session is rebuilt with the
|
||||
// new mode config, which can't happen mid-run. End the run right after
|
||||
// the tool result so the model never keeps working with plan-mode tools
|
||||
// it was just told it no longer has; run-interactive applies the pending
|
||||
// change and auto-continues on the rebuilt session.
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
if (input.config.mode === "act") {
|
||||
return "Already in act mode.";
|
||||
// Throw instead of returning: a successful result would end the
|
||||
// run via completesRun even though nothing changed.
|
||||
throw new Error("Already in act mode.");
|
||||
}
|
||||
input.pendingModeChange.current = "act";
|
||||
input.pendingModeChange.source = "tool";
|
||||
input.tuiModeChanged.current?.("act");
|
||||
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one interactive turn, and when the model ended it by calling
|
||||
* switch_to_act_mode, continues the approved plan on the rebuilt act-mode
|
||||
* session instead of waiting for the user to prompt again.
|
||||
*
|
||||
* The continuation only fires for a tool-initiated switch on a turn that
|
||||
* finished "completed": a UI toggle mid-run aborts the turn, and even if the
|
||||
* toggle races a natural completion its source is "ui", so the user's Tab
|
||||
* press can never start executing a plan they did not approve.
|
||||
*/
|
||||
export async function sendTurnWithActModeContinuation<
|
||||
T extends { finishReason: string; iterations: number },
|
||||
>(input: {
|
||||
sendInitialTurn: () => Promise<T | undefined>;
|
||||
sendContinuationTurn: (prompt: string) => Promise<T | undefined>;
|
||||
applyPendingModeChange: () => Promise<AppliedModeChange | undefined>;
|
||||
}): Promise<T | undefined> {
|
||||
const result = await input.sendInitialTurn();
|
||||
const switched = await input.applyPendingModeChange();
|
||||
if (
|
||||
switched?.mode !== "act" ||
|
||||
switched.source !== "tool" ||
|
||||
result?.finishReason !== "completed"
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
const continuation = await input.sendContinuationTurn(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
// Honor a mode toggle made while the continuation was running.
|
||||
await input.applyPendingModeChange();
|
||||
if (!continuation) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...continuation,
|
||||
iterations: result.iterations + continuation.iterations,
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
|
||||
@@ -210,6 +210,40 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
// Keep the replacement session's start in flight so the restart window
|
||||
// (old session stopped, no active session yet) stays open.
|
||||
const gate = deferred<void>();
|
||||
manager.start.mockImplementationOnce(async () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: { session_id: "session-restarted" },
|
||||
};
|
||||
});
|
||||
|
||||
const restart = runtime.restartWithCurrentMessages();
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A message submitted mid-restart (e.g. right after a plan/act toggle)
|
||||
// calls ensureReady; it must wait for the restart instead of booting a
|
||||
// blank session that races the replacement for the active slot.
|
||||
const ready = runtime.ensureReady();
|
||||
gate.resolve();
|
||||
await Promise.all([restart, ready]);
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
@@ -365,6 +399,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;
|
||||
});
|
||||
@@ -353,15 +383,41 @@ export function createInteractiveSessionRuntime(input: {
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 +566,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 +613,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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
@@ -20,7 +24,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
When the user aligns on a plan and is ready to proceed, use the switch_to_act_mode tool to switch to act mode and begin implementation.`;
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
@@ -31,10 +35,13 @@ export async function resolveSystemPrompt(input: {
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = rules
|
||||
? `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`
|
||||
: PLAN_MODE_INSTRUCTIONS;
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
}
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
@@ -52,7 +53,13 @@ import {
|
||||
type InteractiveExitSummary,
|
||||
} from "./interactive/exit-summary";
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import { createInteractiveModeSwitchTool } from "./interactive/mode";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
@@ -149,8 +156,9 @@ export async function runInteractive(
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
|
||||
const pendingModeChange: { current: "plan" | "act" | null } = {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
@@ -204,6 +212,7 @@ export async function runInteractive(
|
||||
});
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
|
||||
const isInteractiveMode = (mode: unknown): mode is "plan" | "act" =>
|
||||
mode === "plan" || mode === "act";
|
||||
@@ -218,7 +227,11 @@ export async function runInteractive(
|
||||
await modeChangePromise;
|
||||
}
|
||||
await sessionRuntime.ensureReady();
|
||||
const from = config.mode;
|
||||
await sessionRuntime.applyMode(mode);
|
||||
if (isInteractiveMode(from)) {
|
||||
modeSwitchNotice.record(from, mode);
|
||||
}
|
||||
})().finally(() => {
|
||||
if (modeChangePromise === next) {
|
||||
modeChangePromise = undefined;
|
||||
@@ -389,7 +402,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,
|
||||
@@ -520,27 +533,50 @@ export async function runInteractive(
|
||||
...(attachments?.userImages ?? []),
|
||||
...userImages,
|
||||
];
|
||||
// Mark a preceding user-initiated mode switch on this message so
|
||||
// the model sees exactly when the rules changed, instead of only
|
||||
// inferring it from the user_input mode attribute flipping.
|
||||
const switchNotice = modeSwitchNotice.consume();
|
||||
const noticedUserInput = switchNotice
|
||||
? `${formatModeSwitchNotice(switchNotice.from, switchNotice.to)}\n${userInput}`
|
||||
: userInput;
|
||||
|
||||
const applyPendingModeChange = async () => {
|
||||
const applyPendingModeChange = async (): Promise<
|
||||
AppliedModeChange | undefined
|
||||
> => {
|
||||
if (!pendingModeChange.current) return undefined;
|
||||
const newMode = pendingModeChange.current;
|
||||
const applied: AppliedModeChange = {
|
||||
mode: pendingModeChange.current,
|
||||
source: pendingModeChange.source ?? "ui",
|
||||
};
|
||||
pendingModeChange.current = null;
|
||||
await sessionRuntime.applyMode(newMode);
|
||||
tuiModeChanged.current?.(newMode);
|
||||
return newMode;
|
||||
pendingModeChange.source = null;
|
||||
const from = config.mode;
|
||||
await sessionRuntime.applyMode(applied.mode);
|
||||
tuiModeChanged.current?.(applied.mode);
|
||||
// The switch_to_act_mode path announces itself through the
|
||||
// continuation prompt; only UI toggles need a notice.
|
||||
if (applied.source === "ui" && isInteractiveMode(from)) {
|
||||
modeSwitchNotice.record(from, applied.mode);
|
||||
}
|
||||
return applied;
|
||||
};
|
||||
|
||||
const result = await sessionRuntime.sendCurrentTurn({
|
||||
prompt: userInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
const result = await sendTurnWithActModeContinuation({
|
||||
sendInitialTurn: () =>
|
||||
sessionRuntime.sendCurrentTurn({
|
||||
prompt: noticedUserInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
}),
|
||||
sendContinuationTurn: (prompt) =>
|
||||
sessionRuntime.sendCurrentTurn({ prompt, mode: "act" }),
|
||||
applyPendingModeChange,
|
||||
});
|
||||
|
||||
await applyPendingModeChange();
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
@@ -642,6 +678,7 @@ export async function runInteractive(
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
sessionRuntime.abortAll();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
@@ -296,7 +297,13 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const handlePendingPromptSubmitted = useCallback(
|
||||
(event: PendingPromptSubmittedEvent) => {
|
||||
knownPendingPromptIdsRef.current.delete(event.id);
|
||||
appendEntry({ kind: "user_submitted", text: event.prompt });
|
||||
// Display boundary: formatDisplayUserInput strips runtime-generated
|
||||
// notice elements (e.g. mode_notice) that normalizeUserInput must
|
||||
// preserve, since the latter also sanitizes model-bound prompts.
|
||||
appendEntry({
|
||||
kind: "user_submitted",
|
||||
text: formatDisplayUserInput(event.prompt),
|
||||
});
|
||||
},
|
||||
[appendEntry],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
|
||||
describe("hydrateSessionMessages", () => {
|
||||
it("renders regular user messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "lets do it" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the synthetic act-mode continuation prompt", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "On it.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "assistant_text", text: "On it.", streaming: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatDisplayUserInput, type Message } from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
@@ -11,6 +12,12 @@ function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
// The act-mode continuation prompt is runtime-generated, not typed by the
|
||||
// user, so it should not surface as a user bubble in the transcript.
|
||||
function isSyntheticUserText(text: string): boolean {
|
||||
return text === ACT_MODE_CONTINUATION_PROMPT;
|
||||
}
|
||||
|
||||
function stringifyToolResult(
|
||||
content: string | Array<{ type: string; text?: string; path?: string }>,
|
||||
): string {
|
||||
@@ -40,7 +47,9 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.role === "user") {
|
||||
const text = formatDisplayUserInput(msg.content);
|
||||
if (text) entries.push({ kind: "user_submitted", text });
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
} else {
|
||||
entries.push({
|
||||
kind: "assistant_text",
|
||||
@@ -115,7 +124,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (msg.role === "user" && userTextParts.length > 0) {
|
||||
const combined = userTextParts.join("\n");
|
||||
const text = formatDisplayUserInput(combined);
|
||||
if (text) {
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ApiKeyField = ({
|
||||
}: ApiKeyFieldProps) => {
|
||||
const [localValue, setLocalValue] = useState(initialValue)
|
||||
const isFocusedRef = useRef(false)
|
||||
const hasPendingUserEditRef = useRef(false)
|
||||
const prevInitialValueRef = useRef(initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,12 +43,18 @@ export const ApiKeyField = ({
|
||||
// Do not replace their in-progress input with the new mask, or subsequent saves only
|
||||
// persist the suffix typed after that rerender.
|
||||
if (!isFocusedRef.current) {
|
||||
hasPendingUserEditRef.current = false
|
||||
setLocalValue(initialValue)
|
||||
}
|
||||
}, [initialValue])
|
||||
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
if (!hasPendingUserEditRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
hasPendingUserEditRef.current = false
|
||||
onChange(localValue)
|
||||
},
|
||||
100,
|
||||
@@ -63,7 +70,10 @@ export const ApiKeyField = ({
|
||||
onFocus={() => {
|
||||
isFocusedRef.current = true
|
||||
}}
|
||||
onInput={(e: any) => setLocalValue(e.target.value)}
|
||||
onInput={(e) => {
|
||||
hasPendingUserEditRef.current = true
|
||||
setLocalValue((e.target as HTMLInputElement | null)?.value ?? "")
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
required={true}
|
||||
style={{ width: "100%" }}
|
||||
|
||||
+176
-5
@@ -1,3 +1,4 @@
|
||||
import type { ProviderConfigResponse } from "@shared/proto/cline/models"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import type { ChangeEventHandler, ReactNode } from "react"
|
||||
@@ -6,6 +7,8 @@ import { useProviderConfig } from "@/hooks/useProviderConfig"
|
||||
import { useProviderModels } from "@/hooks/useProviderModels"
|
||||
import { GenericProviderSettings } from "./GenericProviderSettings"
|
||||
|
||||
const providerConfig = (config: Partial<ProviderConfigResponse>): ProviderConfigResponse => config as ProviderConfigResponse
|
||||
|
||||
vi.mock("@/hooks/useProviderModels", () => ({
|
||||
useProviderModels: vi.fn(),
|
||||
}))
|
||||
@@ -147,7 +150,11 @@ describe("GenericProviderSettings", () => {
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: { apiKeyLength: 12 } as any, write, commitSelection: vi.fn() })
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 12 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<GenericProviderSettings
|
||||
@@ -168,6 +175,158 @@ describe("GenericProviderSettings", () => {
|
||||
await waitFor(() => expect(write).toHaveBeenCalledWith({ apiKey: "new-secret" }))
|
||||
})
|
||||
|
||||
it("does not clear API keys while provider config is loading", async () => {
|
||||
const write = vi.fn(async () => undefined)
|
||||
vi.mocked(useProviderModels).mockReturnValue({
|
||||
models: {},
|
||||
defaultModelId: "",
|
||||
isLoading: false,
|
||||
isStale: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: undefined, write, commitSelection: vi.fn() })
|
||||
|
||||
render(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
currentMode="act"
|
||||
providerId="deepseek"
|
||||
providerName="DeepSeek"
|
||||
showModelOptions={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not save when provider config hydrates a saved API key", async () => {
|
||||
const write = vi.fn(async () => undefined)
|
||||
vi.mocked(useProviderModels).mockReturnValue({
|
||||
models: {},
|
||||
defaultModelId: "",
|
||||
isLoading: false,
|
||||
isStale: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: undefined, write, commitSelection: vi.fn() })
|
||||
|
||||
const { rerender } = render(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
currentMode="act"
|
||||
providerId="deepseek"
|
||||
providerName="DeepSeek"
|
||||
showModelOptions={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 8 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
rerender(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
currentMode="act"
|
||||
providerId="deepseek"
|
||||
providerName="DeepSeek"
|
||||
showModelOptions={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue("••••••••")).toBeInTheDocument()
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not write a mask if config hydrates after a blurred edit", async () => {
|
||||
const write = vi.fn(async () => undefined)
|
||||
vi.mocked(useProviderModels).mockReturnValue({
|
||||
models: {},
|
||||
defaultModelId: "",
|
||||
isLoading: false,
|
||||
isStale: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: undefined, write, commitSelection: vi.fn() })
|
||||
|
||||
const { rerender } = render(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
currentMode="act"
|
||||
providerId="deepseek"
|
||||
providerName="DeepSeek"
|
||||
showModelOptions={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const apiKeyInput = screen.getByPlaceholderText("Enter API Key...")
|
||||
fireEvent.input(apiKeyInput, { target: { value: "partial-key" } })
|
||||
fireEvent.blur(apiKeyInput)
|
||||
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 8 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
rerender(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
currentMode="act"
|
||||
providerId="deepseek"
|
||||
providerName="DeepSeek"
|
||||
showModelOptions={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(write).not.toHaveBeenCalledWith({ apiKey: "••••••••" })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("still allows users to clear a saved API key", async () => {
|
||||
const write = vi.fn(async () => undefined)
|
||||
vi.mocked(useProviderModels).mockReturnValue({
|
||||
models: {},
|
||||
defaultModelId: "",
|
||||
isLoading: false,
|
||||
isStale: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 8 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
currentMode="act"
|
||||
providerId="deepseek"
|
||||
providerName="DeepSeek"
|
||||
showModelOptions={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.input(screen.getByDisplayValue("••••••••"), { target: { value: "" } })
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalledWith({ apiKey: "" }))
|
||||
})
|
||||
|
||||
it("does not persist mask characters when editing a saved API key", async () => {
|
||||
const write = vi.fn(async () => undefined)
|
||||
vi.mocked(useProviderModels).mockReturnValue({
|
||||
@@ -179,7 +338,11 @@ describe("GenericProviderSettings", () => {
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: { apiKeyLength: 7 } as any, write, commitSelection: vi.fn() })
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 7 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<GenericProviderSettings
|
||||
@@ -208,7 +371,11 @@ describe("GenericProviderSettings", () => {
|
||||
refresh: vi.fn(),
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: { apiKeyLength: 0 } as any, write, commitSelection: vi.fn() })
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 0 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
|
||||
const { rerender } = render(
|
||||
<GenericProviderSettings
|
||||
@@ -226,7 +393,11 @@ describe("GenericProviderSettings", () => {
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalledWith({ apiKey: "max" }))
|
||||
|
||||
vi.mocked(useProviderConfig).mockReturnValue({ config: { apiKeyLength: 3 } as any, write, commitSelection: vi.fn() })
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: providerConfig({ apiKeyLength: 3 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
rerender(
|
||||
<GenericProviderSettings
|
||||
allowsCustomIds={false}
|
||||
@@ -264,7 +435,7 @@ describe("GenericProviderSettings", () => {
|
||||
fingerprint: "fingerprint",
|
||||
})
|
||||
vi.mocked(useProviderConfig).mockReturnValue({
|
||||
config: { baseUrl: "https://custom.example", apiKeyLength: 0 } as any,
|
||||
config: providerConfig({ baseUrl: "https://custom.example", apiKeyLength: 0 }),
|
||||
write,
|
||||
commitSelection: vi.fn(),
|
||||
})
|
||||
|
||||
+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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user