Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 091eccdfe2 test: update GLM 5.2 context window assertions for refreshed catalog 2026-07-03 10:13:23 -07:00
Saoud Rizwan a2a46ae600 chore(sdk): release v0.0.55 2026-07-03 09:56:58 -07:00
Saoud Rizwan 82c9e77de2 style: apply formatter to pre-existing drift 2026-07-03 09:56:52 -07:00
Robin Newhouse dfd0e022a4 Add VS Code SDK compaction strategy setting (#11892)
* Add VS Code SDK compaction strategy setting

* Move compaction strategy setting into SDK

* Preserve stub global settings on compaction update

* Keep ApiProvider settings as proto strings

* Address compaction strategy review feedback
2026-07-02 23:58:47 -07:00
Robin Newhouse f0ec6a35bb fix: advertise run commands as shell strings (#12038) 2026-07-02 21:44:25 -07:00
Morgan CarrandRenee Huang c09d54f5a2 fix(cli): format structured commands in history export (#12023)
Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-07-01 15:53:27 -07:00
Tomás Barreiro 984d70a351 Add the subscription promo code when linking to the dashboard subscription page (#12019)
* Add the subscription promo code when linking to the dashboard page

* revert tests
2026-07-02 00:47:30 +02:00
Bee bbe7b6fd49 fix(hub): hydrate tool results in session message mapping (#12011)
* fix(hub): hydrate tool results in session message mapping

Map historical tool call/use and tool result blocks into webview tool events, including same-message results and following user result messages. Add tests to verify hydrated outputs and block ordering so restored sessions render completed tool interactions correctly.

* feedback
2026-07-01 14:28:13 -07:00
Bee be97d951fa fix: first-prompt truncation (#12022)
* Fix basic compaction first-prompt truncation

Issue: shallow sessions on high-output models such as OpenRouter MiniMax M3 could auto-compact immediately and reduce the initial task prompt to only the leading <user_input> wrapper. Harbor still passed the full task into Cline and session metadata retained it, but the model conversation could receive a truncated first message and respond that the request was empty or cut off.

Root cause: the output-runway target used maxInputTokens - maxTokens for every basic compaction. For MiniMax M3, maxTokens is nearly maxInputTokens, producing a tiny target. Basic compaction then used its last-resort first-user truncation path, and raw messages still contain the user_input envelope, so prefix truncation preserved the wrapper instead of the actionable task.

Fix: only use the output-runway target after the transcript has at least five user-assistant pairs, so early/shallow tasks use the normal trigger-based target. Also prevent first-user truncation unless that first user message alone exceeds the trigger budget, preserving normal first-turn prompts while still allowing genuinely oversized prompts to be reduced. Added regression tests for the MiniMax-style shallow prompt case and the oversized-first-prompt escape hatch.

* Fix compaction budget for huge-output models

Avoid collapsing context-derived input budgets when a model reports an output limit nearly equal to its context window, such as MiniMax M3. In those cases, treating context-output as the input budget causes auto-compaction to trigger on normal-sized prompts.

Only use contextWindow - maxTokens when the derived value remains at least half of the context window. Also simplify long-conversation basic compaction targeting to maxInputTokens * 0.5 instead of applying the default target ratio to maxInputTokens - maxTokens.

Adds regression coverage for MiniMax-style context-only metadata so an 18k-token prompt does not compact against an incorrectly collapsed 12k input budget.

* Guard compaction estimator against cumulative metrics

* Address basic compaction target review comments
2026-07-01 13:51:50 -07:00
Robin Newhouse c331a8f4b6 fix(core): use curated default for legacy provider migration (#12030) 2026-07-01 12:42:42 -07:00
Ara f180f1584d Add first-request failure telemetry (#11852)
* fix(vscode): capture first request failure telemetry

* Fix provider failure telemetry review feedback

* test(vscode): avoid extension host mock matchers

* fix(vscode): use session metadata for provider failure telemetry

* test(vscode): document pre-session auth telemetry skip

* fix(vscode): use turn-scoped provider failure gate

* fix(vscode): include cline pass in auth failure checks

* refactor(vscode): keep provider failure turn count in gate
2026-07-01 10:23:36 -07:00
Ara 9197d15abf feat(vscode): recognize Tencent TokenHub provider (#12028) 2026-07-01 09:58:45 -07:00
Ara 6c0d5c97b1 feat(llms): add Tencent TokenHub provider (#12014) 2026-07-01 09:58:10 -07:00
Dominic Cooney 9a8be88e85 fix(protos): self-heal protoc download when bun skips grpc-tools postinstall (#12003) 2026-07-01 08:31:04 +09:00
Ara 60f4a482ca Add onboarding intent telemetry (#11848)
* Add onboarding intent telemetry

* Track prompt submit intent from chat UI
2026-06-30 14:19:51 -07:00
John Choi dbe15202e1 fix(cli): update ClinePass tests for forced-enabled behavior (#11990)
#11986 (Forcefully enable ClinePass on the CLI) hardcoded isClinePassEnabled: true in session-runtime.ts, provider-catalog.ts, and main.ts and removed the ext-cline-pass feature-flag check, but left the corresponding tests asserting the old flag-driven / disabled behavior. They fail on main (and every branch that merges it).

- session-runtime.test.ts: expect getLastUsedProviderSettings called with isClinePassEnabled: true.

- provider-catalog.test.ts: drop the obsolete getBooleanFlagEnabled('ext-cline-pass') assertion (source no longer reads the flag) and its now-unused mock; keep the isClinePassEnabled: true expectation.

- main.test.ts: the ClinePass flag is no longer read during startup, so getBooleanFlagEnabled is never called. Re-target the 'seed identity before flags' ordering assertion at refreshCliFeatureFlagsInBackground (which is still invoked after seeding), and wire that mock through featureFlagMocks.
2026-06-29 22:35:52 +02:00
BeeandJohn Choi 8d102db392 chore: remove console logs from compaction test (#11983)
Follow up on #11894, this PR removes the console logging code from the compaction unit test.

Co-authored-by: John Choi <97497948+johnwschoi@users.noreply.github.com>
2026-06-29 13:22:05 -07:00
MaxandMax Paulus 🥪 3dfd5dc31c fix(cli): recover missing interactive sessions on message reads (#11984)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-29 13:21:27 -07:00
John Choi 43ce9f3694 fix(vscode): exclude vitest-owned tests from the mocha integration compile (#11981)
The vscode test (Extension Integration Tests) job is red on main: the updateAutoApprovalSettings suite (added in #11929) is vitest-native but was being collected and run by the Mocha @vscode/test-cli runner, where vitest-only matchers (toHaveBeenCalledWith / toHaveBeenCalledOnce / not.toHaveBeenCalled) are not registered, failing with 'is not a function'.

build-tests.js already excludes bun:test-owned tests from the Node-based out/ tree (single source of truth for the runner split). Extend that same mechanism to also exclude vitest-owned tests (files importing from 'vitest'), so neither bun nor vitest suites are ever compiled into the mocha out/ tree. Verified locally: detection catches the state suite (123 non-mocha test files total) while preserving the existing 60 bun __tests__ exclusions. No coverage lost — these suites run under test:vitest / bun.
2026-06-29 12:51:52 -07:00
Tomás Barreiro f1c73fb48b Remove unused imports (#11988) 2026-06-29 12:39:17 -07:00
Tomás Barreiro abaa8383c4 Forcefully enable ClinePass on the CLI (#11986) 2026-06-29 21:31:51 +02:00
Renee Huang 3a5e372d73 docs: document ClinePass API usage (#11980)
* docs: add MiniMax M3, Qwen3.7 Max, Qwen3.7 Plus models to ClinePass page

* more updates

* explicitly direct to personal org

* making the clinepass page more detailed

* updates to cline provider wording

* docs: document ClinePass API usage

* chore: discard McpHub change from PR

* docs: simplify ClinePass model slug table

* updates

* nit
2026-06-29 10:59:02 -07:00
Saoud Rizwan cf3a59f0e2 chore(cli): release v3.0.34 2026-06-29 09:59:28 -07:00
Tomás BarreiroandSaoud Rizwan b3aee68ca5 Merge both options and remove credits link (#11973)
* Merge both options and remove credits link

* Remove import

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-29 09:48:57 -07:00
Tomás BarreiroandSaoud Rizwan cd8fd29063 Improve the ClinePass step wording (#11974)
* Improve the ClinePass step wording

* Use a blacklist instead

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-29 09:21:27 -07:00
Saoud Rizwan 7777d61311 fix(cli): suppress ClinePass notice after onboarding (#11975) 2026-06-29 09:20:35 -07:00
Renee Huang 64fc3f372e docs: add MiniMax M3, Qwen3.7 Max, Qwen3.7 Plus models to ClinePass page (#11849)
* docs: add MiniMax M3, Qwen3.7 Max, Qwen3.7 Plus models to ClinePass page

* more updates

* explicitly direct to personal org

* making the clinepass page more detailed

* updates to cline provider wording
2026-06-29 07:28:38 -07:00
Saoud Rizwan 4175677e71 chore(cli): release v3.0.33 2026-06-28 23:45:56 -07:00
Saoud Rizwan 4934450947 fix(cli): show ClinePass subscription URL fallback (#11961)
* fix(cli): show ClinePass subscription URL fallback

* fix(cli): move ClinePass URL fallback below options
2026-06-28 23:33:58 -07:00
Saoud Rizwan b0a2d8a223 fix(cli): hide ClinePass promo for ClinePass users (#11963)
* fix(cli): hide ClinePass promo for ClinePass users

* fix(cli): expand ClinePass subscription card

* fix(cli): tune ClinePass subscription card height
2026-06-28 23:33:19 -07:00
Saoud Rizwan d9f1d862a5 fix(cli): use adaptive plan accent for ClinePass prompts (#11962) 2026-06-28 23:12:30 -07:00
Saoud Rizwan f8d73f3811 chore(cli): release v3.0.32 2026-06-28 21:43:18 -07:00
Saoud Rizwan 9aac8340dc chore(sdk): regenerate bun.lock for v0.0.54
Align resolved workspace versions in bun.lock with the v0.0.54 package
bumps. bun pm pack substitutes workspace:* deps using the version
recorded in bun.lock, so a stale lock made packed inter-package deps
resolve to 0.0.53, failing check-publish and the node smoke test (which
then pulled the old published @cline/shared from npm).
2026-06-28 21:24:41 -07:00
Saoud Rizwan 242b5ebff6 chore(sdk): release v0.0.54 2026-06-28 20:54:47 -07:00
Tomás Barreiro 7ca41fdb7d Improve ClinePass onboarding UX (#11959)
* Prevent ClinePass onboarding flicker

* Make the clinepass step scrollable and remove details
2026-06-28 20:49:27 -07:00
Saoud Rizwan 000918989f fix(cli): make ClinePass subscription screen selectable (#11957) 2026-06-28 20:12:04 -07:00
Saoud Rizwan 9560b6d625 fix(cli): use ClinePass as one word consistently (#11956) 2026-06-28 18:41:26 -07:00
Saoud Rizwan c7304097a7 fix(cli): update ClinePass provider UI copy (#11953)
* fix(cli): update ClinePass provider UI copy

* fix(llms): rename Cline provider display name

* fix(cli): separate ClinePass billing links
2026-06-28 17:57:11 -07:00
Tomás BarreiroandSaoud Rizwan 674a6022ee Add an intermediate step before going to ClinePass model selection (#11947)
* Add an intermediate step before going to model selection

* fix type issues

* use allSettled

* fix(cli): serialize ClinePass subscription checks

* fix(cli): handle missing ClinePass plan as unsubscribed

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-28 16:13:25 -07:00
Saoud Rizwan dbf0775384 fix(llms): keep error detail extraction for Error instances (#11949)
PR #11928 added an `instanceof Error` branch to extractErrorMessage to
preserve transport-error wrappers (e.g. "fetch failed: SocketError: ...
(UND_ERR_SOCKET)"), but that branch regressed two cases:

- Generic SDK wrappers like "No output generated. Check the stream for
  errors." were prepended to the real cause instead of being dropped.
- Errors carrying structured detail on responseBody/detail/error fields
  surfaced the bland top-level .message ("Bad Request") instead of the
  detail ("Instructions are required").

The Error branch now drops known generic wrappers in favor of the cause
and extracts structured detail from the error's own fields, while keeping
the transport-wrapper concat behavior #11928 intended.
2026-06-28 15:27:46 -07:00
Saoud Rizwan e130b45eb0 feat(cli): promote Cline Pass in startup notice (#11948) 2026-06-28 13:14:27 -07:00
6f4dbae86f Improve the ClinePass onboarding experience on the CLI [ENG-2236] (#11946)
* Improve the ClinePass onboarding experience on the CLI

* Update apps/cli/src/tui/views/onboarding/screens.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* address comments

* style(cli): format ClinePass onboarding warning

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-28 10:53:27 -07:00
Saoud Rizwan c7de31ae24 ci(vscode): gate legacy publish on test job (#11936) 2026-06-27 19:33:37 -07:00
Saoud Rizwan 45dddb9a4e ci(vscode): add legacy extension publish workflow (#11935) 2026-06-27 19:17:46 -07:00
BeeandSaoud Rizwan e32caee96b fix: basic compaction token budgeting (#11894)
* Improve compaction token budgeting

Use MessageWithMetadata.metrics input/output token counts when estimating message size for compaction, falling back to the existing chars/3 heuristic only when metrics are unavailable. This makes trigger decisions and post-compaction accounting use provider-reported token usage instead of relying only on serialized character estimates.

When an explicit compaction maxInputTokens budget is configured and the model exposes maxTokens, reserve half of the model output budget before triggering compaction. This gives the next provider request room for completion tokens and reduces edge cases where the local context estimate passes but the provider rejects the prompt as exceeding its limit.

Keep explicit reserveTokens and thresholdRatio overrides intact, and add regression coverage for metric-based token estimation, fallback estimation, and output-token-aware trigger budgeting.

* new target tokens and trigger tokens value

* fixes

* use imports and add unit test

* resolveMaxInputTokens

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-27 16:04:56 -07:00
Saoud Rizwan 8f6dae0ac0 fix(vscode): sync auto-approve task settings (#11929) 2026-06-27 15:13:14 -07:00
Saoud Rizwan 263b58f8c3 fix(llms): preserve fetch error cause details (#11928) 2026-06-27 14:51:56 -07:00
Saoud Rizwan b193a81ac9 fix: prevent API key field clearing on settings load (#11925)
* fix: prevent API key field clearing on settings load

* fix: cancel API key save on mask hydration
2026-06-27 14:32:14 -07:00
John Choi 92806c60ca fix(agents): derive messageModelInfo in the provider/model runtime path (#11903)
The standalone AgentRuntime({ providerId, modelId }) constructor built the gateway model in resolveRuntimeConfig and returned { ...rest, model } without deriving messageModelInfo. The prebuilt-model path preserves it, and core sessions populate it via buildMessageModelInfo, but standalone SDK callers lost it -- so assistant-message modelInfo and model-tagged telemetry (reasoning tokens, action-follow-through) emitted without provider/model dimensions.

Derive messageModelInfo as { id: modelId, provider: providerId } in that path (family omitted; it is optional and unavailable here). An explicit caller-provided messageModelInfo still wins. Adds provider-form tests covering both the derived and explicit-override cases.
2026-06-26 18:23:54 -07:00
Saoud Rizwan 3a05171e30 fix(sdk): preserve failed run error messages (#11904) 2026-06-26 18:04:53 -07:00
Saoud Rizwan a6e315a4a6 chore(cli): release v3.0.31 2026-06-26 17:37:36 -07:00
Saoud Rizwan 2714f93b45 chore(sdk): drop volatile catalog refresh from v0.0.53
The bun run version catalog regen dropped the xiaomi mimo-v2-omni,
mimo-v2-pro, and mimo-v2-flash models from the live data. mimo-v2-omni
is the xiaomi provider's defaultModelId in builtins.ts, so shipping the
refreshed catalog would point the default at a missing model and broke
the provider-ids test. Revert the catalog to the pre-release state and
ship v0.0.53 as a pure SDK code release; the catalog will refresh in a
later release once upstream data is stable.
2026-06-26 17:17:00 -07:00
Saoud Rizwan 3abeb8a90b chore(sdk): release v0.0.53 2026-06-26 17:03:04 -07:00
Tomás Barreiro 7830472017 Add open subscription page option to the ClinePass options (#11896)
* Add open subscription page option to the ClinePass options

* Address comments
2026-06-27 01:07:11 +02:00
Bee 83339c3c5a refactor: extension to use sdk provider list (#11888)
* refactor(vscode): use string type for api provider in proto

Replace the ApiProvider proto enum with plain string fields across\nmodels.proto and state.proto, and drop the enum<->string conversion\nmappings in api-configuration-conversion.ts. Updates ApiOptions and\nOpenAICompatible settings components accordingly.

* fix custom provider render

* format

* id

* remove extension providers file

* uses includes
2026-06-26 16:05:55 -07:00
Tomás Barreiroandgreptile-apps[bot] 664daf6ded Show cost has been covered by the users subscription (#11889)
* Show cost has been covered by the users subscription

* fix tests

* Update apps/cli/src/tui/components/status-bar.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update wording

* Update tests

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-26 23:34:06 +02:00
Tomás Barreiro 5b1f8850af Update coupon code (#11890) 2026-06-26 23:33:11 +02:00
Tomás Barreiro c49d4121a3 Fix SDK tests (#11895) 2026-06-26 14:20:58 -07:00
Tomás Barreiro 7f495a5e99 Open ClinePass subscribe page (#11891) 2026-06-26 22:54:00 +02:00
1e88a708bd upate changelog (#11886)
* upate changelog

* Update CHANGELOG.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-26 12:50:52 -07:00
Saoud Rizwan 408be18be9 fix(ci): harden ext-vscode stable release workflow (#11887)
* fix(ci): harden ext-vscode stable release workflow

- Resolve previous tag to the latest vX.Y.Z ancestor instead of the most
  recent reachable tag. The nightly workflow now pushes a nightly-main-*
  annotated tag on every main commit, so git describe was resolving the
  release notes' Full Changelog compare link to a nightly tag rather than
  the prior release tag.
- Extract the changelog section by exact version heading (and fail if it
  is missing) instead of always taking the first ## [ block, so a stale
  top entry can no longer ship as the release notes for a different
  version.
- Add a pre-publish Verify Changelog Entry gate so a release cannot be
  published unless CHANGELOG.md leads with the version being released.
- Add a Verify Marketplace Tokens gate so a missing VSCE_PAT/OVSX_PAT
  fails fast before packaging rather than mid-publish.
- In existing-tag mode, require the tag to point at the tested SHA so the
  published artifact always matches what CI verified.
- Add a concurrency group keyed on the tag to prevent duplicate
  concurrent publishes of the same release.

* fix(ci): validate stable release metadata before publish
2026-06-26 12:50:28 -07:00
Saoud Rizwan 5a9c637e32 fix(core): cap MCP tool names at 64 chars for OpenAI-compatible providers (#11885) 2026-06-26 12:42:57 -07:00
Saoud Rizwan 8152572641 fix(vscode): disable MCP marketplace tab from remote config (#11883) 2026-06-26 11:48:07 -07:00
Saoud Rizwan 0736e12e32 fix(vscode): refresh MCP hub after marketplace install (#11882) 2026-06-26 11:41:34 -07:00
Saoud Rizwan 690f80523f fix(vscode): preserve migrated OpenAI-compatible settings (#11880)
* fix(vscode): preserve migrated OpenAI-compatible settings

* test(vscode): cover OpenAI-compatible plan act selections
2026-06-26 10:56:50 -07:00
Saoud Rizwan 4fa1b8a291 fix(vscode): reject approvals from composer feedback (#11874) 2026-06-26 04:01:10 -07:00
Saoud Rizwan ed685b28e0 feat(vscode): allow cancelling queued prompts (#11875) 2026-06-26 03:58:46 -07:00
Saoud Rizwan 38338310d7 fix(vscode): timeout terminal cwd setup (#11871)
* fix(vscode): timeout terminal cwd setup

* refactor(vscode): simplify terminal cwd timeout
2026-06-26 03:49:36 -07:00
Saoud Rizwan 1c4a7885e6 fix(vscode): keep command output pinned to bottom (#11873) 2026-06-26 03:49:05 -07:00
Saoud Rizwan a0517db2fa feat: add shared marketplace uninstall support (#11870)
* feat: add shared marketplace uninstall support

* fix: avoid regex backtracking in marketplace skill sanitization

* fix: address marketplace uninstall review feedback

* fix: clean up remaining marketplace skill installs

* fix: remove marketplace skills from all agents

* fix: keep customize primitive tabs horizontal
2026-06-26 03:47:53 -07:00
Saoud Rizwan 38134ef967 fix(vscode): surface plugin bundled skills (#11868)
* fix(vscode): surface plugin bundled skills

* fix(core): align plugin skill settings lookup
2026-06-25 21:31:45 -07:00
Saoud Rizwan 8715cafce7 fix(vscode): rename user message reset actions (#11869) 2026-06-25 21:30:46 -07:00
Saoud Rizwan 46ee8ea329 feat(vscode): add customize section tabs (#11867)
* feat(vscode): add customize section tabs

* fix(vscode): reset customize section tab

* fix(vscode): reset customize section on initial type
2026-06-25 21:29:30 -07:00
Tomás BarreiroandSaoud Rizwan b7d9ea4500 Add a prompt to change to ClinePass when out of credits (#11866)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-25 21:11:23 -07:00
Saoud Rizwan 5147abc75e fix: hide workflows from customize menu (#11864) 2026-06-25 20:57:10 -07:00
Saoud Rizwan 9abd7ae8c3 fix(vscode): disable command auto-approval by default (#11865) 2026-06-25 20:56:30 -07:00
Saoud Rizwan bf83303bb7 fix(cli): require quoted prompts for one-shot mode (#11861) 2026-06-25 20:54:13 -07:00
211 changed files with 8890 additions and 2453 deletions
@@ -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 }}"
+65 -19
View File
@@ -27,6 +27,10 @@ permissions:
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/ext-vscode-test.yml
@@ -102,7 +106,13 @@ jobs:
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
echo "Using existing tag '$TAG'."
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
exit 1
fi
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
fi
git checkout --detach "$TAG_REF^{commit}"
@@ -170,6 +180,60 @@ jobs:
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: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
| head -n 1 || true
)
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between the matching version heading and the next release heading.
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
$0 == "## [" version "]" { found=1; next }
found && /^## \[/ { exit }
found { print }
END { if (!found) exit 1 }
' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -210,24 +274,6 @@ jobs:
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:
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## [4.0.0]
### Added
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
### Changed
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
### Fixed
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
## [3.89.2]
### Fixed
+31
View File
@@ -1,5 +1,36 @@
# Cline CLI Changelog
## 3.0.34
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
- Improved the wording of the ClinePass onboarding step.
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
## 3.0.33
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
- Hide the ClinePass promo for users who already have a ClinePass subscription
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
## 3.0.32
- Improved the ClinePass onboarding experience
- Added an intermediate step before going to ClinePass model selection
- Made the ClinePass subscription screen selectable
- Promoted ClinePass in the startup notice
- Used "ClinePass" as one word consistently and refined the provider UI copy
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
## 3.0.31
- Show when request cost is covered by your Cline subscription
- Prompt to switch to ClinePass when you run out of credits, and list ClinePass features in the not-subscribed message
- Added an option to open the subscription page from the ClinePass options
- Added marketplace uninstall support and surfaced plugin-bundled skills
- Require quoted prompts for one-shot mode
- Capped MCP tool names at 64 characters for OpenAI-compatible providers
- Updated coupon code
## 3.0.30
- Added a token count to the status bar, shown alongside cost
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.30",
"version": "3.0.34",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+39
View File
@@ -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,
});
});
+1 -3
View File
@@ -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() ||
+37 -16
View File
@@ -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>
);
}
+71 -9
View File
@@ -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();
});
});
+31 -5
View File
@@ -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",
};
}
+146 -47
View File
@@ -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();
@@ -407,7 +417,7 @@ describe("runCli lightweight command dispatch", () => {
it("does not load interactive runtime for single-prompt mode", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
@@ -417,6 +427,30 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects a single bare positional prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: nonexistent-command",
),
);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Use "cline --help"'),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects multiple bare positional prompt tokens", async () => {
const consoleError = vi
.spyOn(console, "error")
@@ -430,7 +464,7 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or extra arguments: hello world",
"Unknown command or unquoted prompt: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
@@ -474,7 +508,7 @@ describe("runCli lightweight command dispatch", () => {
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
const { runCli } = await import("./main");
@@ -483,7 +517,7 @@ describe("runCli lightweight command dispatch", () => {
cwd: process.cwd(),
});
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
cwd: "/tmp/cline-worktree",
workspaceRoot: "/tmp/cline-worktree",
@@ -606,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", {
@@ -638,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");
@@ -727,7 +792,7 @@ describe("runCli lightweight command dispatch", () => {
it("uses the bundled catalog path for single-prompt runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
@@ -918,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",
@@ -937,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],
);
});
@@ -1013,7 +1081,7 @@ describe("runCli lightweight command dispatch", () => {
it("skips hub prewarm for yolo runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
const { runCli } = await import("./main");
@@ -1022,6 +1090,24 @@ describe("runCli lightweight command dispatch", () => {
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("rejects yolo runs with a single bare prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: hello"),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
runtimeMocks.runAgent.mockClear();
@@ -1042,12 +1128,12 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("shows /team usage in single-prompt mode when no task is provided", async () => {
it("rejects /team without quoted task text", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team"];
@@ -1055,9 +1141,10 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(mockState.runAgentCalls).toBe(0);
expect(stdoutWrite).toHaveBeenCalledWith(
expect.stringContaining("Usage: /team <task description>"),
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: /team"),
);
});
@@ -1066,14 +1153,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1087,14 +1174,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: undefined,
reasoningEffort: undefined,
@@ -1108,14 +1195,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "none", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1129,14 +1216,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1159,14 +1246,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1185,14 +1272,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1211,14 +1298,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "low",
@@ -1232,13 +1319,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1254,13 +1341,19 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"basic",
"say hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1276,13 +1369,19 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"agentic",
"say hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1332,13 +1431,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: false,
@@ -1377,7 +1476,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "hello"];
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
const { runCli } = await import("./main");
@@ -1385,7 +1484,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
@@ -1404,7 +1503,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "hello"];
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
const { runCli } = await import("./main");
@@ -1412,7 +1511,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
+24 -11
View File
@@ -20,7 +20,6 @@ import {
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
@@ -117,6 +116,19 @@ function collectOption(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
// Shells strip quote characters before argv reaches us, so a prompt that was
// typed in quotes is only observable when it remains one argv token with spaces.
function promptArgLooksQuoted(arg: string | undefined): boolean {
return !!arg && /\s/.test(arg);
}
function writePromptArgError(args: string[]): void {
const renderedArgs = args.join(" ");
writeErr(
`Unknown command or unquoted prompt: ${renderedArgs}\nPrompt text must be passed as a single quoted argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
}
export async function runCli(): Promise<void> {
installStreamErrorGuards();
autoUpdateOnStartup();
@@ -736,13 +748,6 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
if (program.args.length > 1) {
writeErr(
`Unknown command or extra arguments: ${program.args.join(" ")}\nPrompt text with spaces must be quoted as a single argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
process.exitCode = 1;
return;
}
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
@@ -829,6 +834,13 @@ export async function runCli(): Promise<void> {
if (args.hooksDir?.trim()) {
process.env.CLINE_HOOKS_DIR = args.hooksDir.trim();
}
if (args.prompt && !args.interactive) {
if (program.args.length > 1 || !promptArgLooksQuoted(program.args[0])) {
writePromptArgError(program.args);
process.exitCode = 1;
return;
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
@@ -943,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",
@@ -1169,7 +1180,9 @@ export async function runCli(): Promise<void> {
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
await import("./kanban-migration/notice");
initialNotice = getClineCliMigrationNotice();
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
activeProviderId: provider,
});
if (initialNotice) {
markInitialNoticeShown = () => {
markClineCliMigrationNoticeShown();
@@ -365,6 +365,48 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
new SessionNotFoundError("session-1"),
);
const runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
initialMessages: [],
}),
);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
const manager = makeManager();
let runtime!: ReturnType<typeof makeRuntime>;
manager.readMessages.mockImplementationOnce(async () => {
await runtime.restartEmpty();
return [
{
role: "user" as const,
content: [{ type: "text" as const, text: "stale" }],
},
];
});
runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
const manager = makeManager();
const recoveryRead = deferred<Message[]>();
@@ -49,6 +49,13 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type CurrentMessagesRead =
| { messages: Message[]; status: "read" }
| { messages: Message[]; status: "recovered" }
| { messages: Message[]; status: "stale" };
type MissingSessionRecovery = {
messages: Message[];
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
@@ -103,7 +110,9 @@ export function createInteractiveSessionRuntime(input: {
let shutdownRequested = false;
let activeSessionId = "";
let abortRequested = false;
let missingSessionRecoveryPromise: Promise<void> | undefined;
let missingSessionRecoveryPromise:
| Promise<MissingSessionRecovery>
| undefined;
// A reset can happen while an earlier manager.start() is still in flight.
// Bump this before resets and restarts so stale starts cannot become active.
let sessionStartGeneration = 0;
@@ -275,14 +284,34 @@ export function createInteractiveSessionRuntime(input: {
return await startupPromise;
};
const readCurrentMessages = async (): Promise<Message[]> => {
if (!sessionManager || !activeSessionId) {
return [];
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
return { messages: [], status: "read" };
}
try {
const messages = (await manager.readMessages(sessionId)) ?? [];
return {
messages,
status: activeSessionId === sessionId ? "read" : "stale",
};
} catch (error) {
if (
abortRequested ||
shutdownRequested ||
!isSessionNotFoundError(error)
) {
throw error;
}
const recovery = await recoverMissingActiveSession(error);
return { messages: recovery.messages, status: "recovered" };
}
return (await sessionManager.readMessages(activeSessionId)) ?? [];
};
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
const recoverMissingActiveSession = async (
error: unknown,
): Promise<MissingSessionRecovery> => {
if (missingSessionRecoveryPromise) {
return await missingSessionRecoveryPromise;
}
@@ -290,7 +319,7 @@ export function createInteractiveSessionRuntime(input: {
const manager = sessionManager;
const missingSessionId = activeSessionId;
if (!manager || !missingSessionId || shutdownRequested) {
return;
return { messages: [] };
}
const messages = await manager
.readMessages(missingSessionId)
@@ -307,6 +336,7 @@ export function createInteractiveSessionRuntime(input: {
startupError = undefined;
clearActiveSession();
await startFreshSession(messages);
return { messages };
})().finally(() => {
missingSessionRecoveryPromise = undefined;
});
@@ -361,7 +391,13 @@ export function createInteractiveSessionRuntime(input: {
};
const restartWithCurrentMessages = async (): Promise<void> => {
const messages = await readCurrentMessages();
const { messages, status } = await readCurrentMessages();
if (status !== "read") {
// If reading recovered a missing hub session, the current messages are
// already in the replacement session. If the read is stale, another async
// operation changed the active session while this read was in flight.
return;
}
await restartWithMessages(messages);
};
@@ -510,7 +546,13 @@ export function createInteractiveSessionRuntime(input: {
if (!sessionManager) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const messages = await readCurrentMessages();
const { messages, status } = await readCurrentMessages();
if (status === "stale" || (status === "recovered" && !activeSessionId)) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
// If reading messages recovered the session, `messages` are the same messages
// used to seed the replacement session, so it is safe to compact the current
// active session with them.
const messagesBefore = messages.length;
if (messagesBefore === 0) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
@@ -551,7 +593,10 @@ export function createInteractiveSessionRuntime(input: {
return undefined;
}
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
const messages = await readCurrentMessages();
const { messages, status } = await readCurrentMessages();
if (status !== "read") {
return undefined;
}
return { messages, checkpointHistory };
};
+1 -1
View File
@@ -40,7 +40,7 @@ const sessionEventsMocks = vi.hoisted(() => ({
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-100&personal=true";
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
+1 -1
View File
@@ -389,7 +389,7 @@ export async function runInteractive(
? async () => {
try {
await sessionRuntime.ensureReady();
const messages = await sessionRuntime.readCurrentMessages();
const { messages } = await sessionRuntime.readCurrentMessages();
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
+4 -3
View File
@@ -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>
`,
)
+1 -1
View File
@@ -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,
+7
View File
@@ -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();
});
+46 -1
View File
@@ -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({
+41 -21
View File
@@ -6,6 +6,7 @@ import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
@@ -16,6 +17,7 @@ import {
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getModeInputBackground,
palette,
type TerminalTheme,
@@ -36,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+/, "");
}
@@ -273,7 +269,8 @@ function ToolCallView(props: {
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getCliSubscriptionUrl();
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -288,27 +285,47 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
<text
fg={props.defaultFg}
selectable
content="You have run out of Cline credits. Add credits in the dashboard to continue."
content={
"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) {
@@ -333,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
@@ -351,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>
)}
@@ -379,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
@@ -504,6 +522,7 @@ export function ChatEntryView(props: {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
);
}
@@ -514,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;
@@ -248,18 +249,33 @@ export function ProviderPickerContent(
);
}
export type ExistingProviderAction = "use_existing" | "reconfigure";
export type ExistingProviderAction =
| "use_existing"
| "reconfigure"
| "open_subscription_page"
| "open_usage_billing";
export interface ExistingProviderOption {
value: ExistingProviderAction;
label: string;
onSelect?: () => Promise<void> | void;
}
export function UseExistingOrReconfigureContent(
props: ChoiceContext<ExistingProviderAction> & {
props: ChoiceContext<ExistingProviderOption> & {
providerName: string;
extraOptions?: ExistingProviderOption[];
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const options: { value: ExistingProviderAction; label: string }[] = [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
];
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
const options: ExistingProviderOption[] = useMemo(
() => [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
...(extraOptions ?? []),
],
[extraOptions],
);
const [selected, setSelected] = useState(0);
useDialogKeyboard((key) => {
@@ -269,7 +285,7 @@ export function UseExistingOrReconfigureContent(
}
if (key.name === "return" || key.name === "enter") {
const opt = options[selected];
if (opt) resolve(opt.value);
if (opt) resolve(opt);
return;
}
if (key.name === "up" || (key.ctrl && key.name === "p")) {
@@ -314,6 +330,86 @@ export function UseExistingOrReconfigureContent(
);
}
function ClinePassBrowserPageContent(
props: ChoiceContext<boolean> & {
providerName: string;
pageLabel: string;
url: string;
openedStatus: string;
},
) {
const {
resolve,
dismiss,
dialogId,
providerName,
pageLabel,
url,
openedStatus,
} = props;
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
void open(url, { wait: false })
.then(() => {
setStatus(openedStatus);
})
.catch(() => {
setStatus("Could not open browser automatically. Open the URL below.");
});
}, [url, openedStatus]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return" || key.name === "enter") {
resolve(true);
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<strong>{providerName}</strong>
</text>
<text>{status}</text>
<text fg="gray">{pageLabel}:</text>
<text fg="cyan" selectable>
<a href={url}>{url}</a>
</text>
<text fg="gray">
<em>Enter or Esc to go back</em>
</text>
</box>
);
}
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",
+31 -4
View File
@@ -3,6 +3,7 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -55,18 +56,44 @@ describe("formatStatusBarUsageText", () => {
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
showCost: true,
providerId: "cline",
}),
).toBe("(12,345 tokens) $0.12");
});
it("omits cost when usage cost is hidden", () => {
it("displays subscription message when the provider is a subscription provider", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
showCost: false,
providerId: "cline-pass",
}),
).toBe("(12,345 tokens)");
).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");
});
});
+33 -9
View File
@@ -1,6 +1,9 @@
import type { AgentMode } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
@@ -46,14 +49,31 @@ function formatCost(cost: number): string {
return `$${cost.toFixed(2)}`;
}
function formatCostText(providerId: string, totalCost: number): string {
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
return "$0.00 (included with subscription)";
}
if (!shouldShowCliUsageCost(providerId)) {
return "";
}
return formatCost(totalCost);
}
export function formatStatusBarUsageText(input: {
totalTokens: number;
totalCost: number;
showCost: boolean;
providerId: string;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
const costText = formatCostText(input.providerId, input.totalCost);
if (!costText) {
return tokens;
}
return `${tokens} ${costText}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
@@ -74,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: {
@@ -152,7 +177,6 @@ export function StatusBar(props: StatusBarProps) {
const bar = hasMaxInputTokens
? createContextBar(totalTokens, maxInputTokens)
: undefined;
const showUsageCost = shouldShowCliUsageCost(props.providerId);
// Available content width after accounting for padding.
// Home view: parent box is capped at 60 wide, status bar adds paddingX=1 (-2).
@@ -169,7 +193,7 @@ export function StatusBar(props: StatusBarProps) {
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
showCost: showUsageCost,
providerId: props.providerId,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
+58 -8
View File
@@ -18,8 +18,9 @@ import {
import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderAction,
type ExistingProviderOption,
OAuthLoginContent,
ProviderConfigInputContent,
ProviderPickerContent,
@@ -78,6 +79,36 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
dialog: DialogActions;
termHeight: number;
}): ExistingProviderOption[] {
if (input.providerId !== "cline-pass") {
return [];
}
return [
{
value: "open_subscription_page",
label: "Manage subscription & see usage",
onSelect: async () => {
await input.dialog.choice<boolean>({
style: { maxHeight: input.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<ClinePassSubscriptionContent
{...ctx}
providerName={input.providerName}
/>
),
});
},
},
];
}
async function runProviderChange(
dialog: DialogActions,
config: Config,
@@ -102,14 +133,33 @@ async function runProviderChange(
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
const action = await dialog.choice<ExistingProviderAction>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
),
let option: ExistingProviderOption | undefined;
const extraOptions = providerToExistingProviderOptions({
providerId: newProviderId,
providerName: displayName,
dialog,
termHeight,
});
if (!action) return false;
needsAuth = action === "reconfigure";
while (true) {
option = await dialog.choice<ExistingProviderOption>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderOption>) => (
<UseExistingOrReconfigureContent
{...ctx}
providerName={displayName}
extraOptions={extraOptions}
/>
),
});
if (!option) return false;
if (option.onSelect) {
await option.onSelect();
option = undefined;
continue;
}
break;
}
needsAuth = option.value === "reconfigure";
}
if (needsAuth) {
+9 -1
View File
@@ -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,
+150 -7
View File
@@ -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;
+209 -3
View File
@@ -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
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliNotSubscribedMessage,
getCliSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -27,7 +27,7 @@ describe("cline-pass-errors", () => {
it("formats the ClinePass subscription URL", () => {
expect(getCliSubscriptionUrl()).toBe(
"https://app.cline.bot/promo?code=CLI-100&personal=true",
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
);
});
+13 -4
View File
@@ -1,4 +1,5 @@
import {
type ClineSubscriptionPlan,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
@@ -8,13 +9,13 @@ import {
import { getClineEnvironmentConfig } from "@cline/shared";
export {
getClineOrgIndividualInferenceSubscriptionMessage,
};
export { getClineOrgIndividualInferenceSubscriptionMessage };
export const CLI_PROMO_CODE = "CLI-8OFF";
export function getCliSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-100&personal=true",
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
@@ -23,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 (
+1 -1
View File
@@ -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;
}
+1 -9
View File
@@ -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,
});
+1 -3
View File
@@ -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,
});
}
+6
View File
@@ -3,3 +3,9 @@ import { Llms } from "@cline/core";
export function shouldShowCliUsageCost(providerId: string): boolean {
return Llms.shouldShowProviderUsageCost(providerId);
}
export function shouldShowCliUsageCoveredBySubscription(
providerId: string,
): boolean {
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
}
+15 -6
View File
@@ -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;
+43 -29
View File
@@ -51,6 +51,36 @@ describe("marketplace installer", () => {
vi.restoreAllMocks();
});
function createInstalledOfficialPlugin(
clineDir: string,
slug: string,
): string {
const sourceKey = `official:https://github.com/cline/plugins.git#plugins/${slug}`;
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
const installPath = join(
clineDir,
"plugins",
"_installed",
"official",
`${slug}-${hash}`,
);
mkdirSync(join(installPath, "package"), { recursive: true });
writeFileSync(
join(installPath, "package.json"),
JSON.stringify({ name: slug }, null, 2),
"utf8",
);
writeFileSync(
join(installPath, "package", "index.ts"),
`export default { name: "${slug}", manifest: { capabilities: ["tools"] } };`,
"utf8",
);
return installPath;
}
it("maps remote MCP catalog args to MCP settings shape", () => {
expect(
buildMarketplaceMcpInput([
@@ -288,8 +318,6 @@ describe("marketplace installer", () => {
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
@@ -535,16 +563,13 @@ describe("marketplace installer", () => {
]);
});
it("runs official plugin uninstalls through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
it("uninstalls official marketplace plugins through the shared core service", async () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stdout: "",
stderr: "",
}));
@@ -565,12 +590,8 @@ describe("marketplace installer", () => {
message: "Uninstalled Goal.",
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
@@ -614,15 +635,12 @@ describe("marketplace installer", () => {
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stdout: "",
stderr: "",
}));
@@ -650,12 +668,8 @@ describe("marketplace installer", () => {
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
+18 -103
View File
@@ -18,8 +18,11 @@ import {
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
@@ -792,50 +795,6 @@ async function installSkill(
};
}
async function uninstallSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installedName = findInstalledGlobalSkillName(entry);
if (!installedName) {
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `${entry.name ?? entry.id} is not installed.`,
};
}
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"remove",
installedName,
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
@@ -886,47 +845,6 @@ async function installPlugin(
};
}
async function uninstallPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
const target = installArgs[0]?.trim() || entry.id;
if (!target) {
throw new Error("Plugin marketplace uninstalls require a plugin name.");
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"uninstall",
target,
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
@@ -983,24 +901,21 @@ export async function uninstallMarketplaceEntry(
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = deleteMcpServer(String(input.name ?? ""));
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return uninstallSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return uninstallPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
}
export async function installMarketplaceEntryFromCatalog(
+6 -3
View File
@@ -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",
},
});
});
});
+273 -8
View File
@@ -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;
});
@@ -13,7 +13,9 @@ service MarketplaceService {
rpc listMarketplaceLocalInstalledEntries(EmptyRequest) returns (MarketplaceLocalInstalledEntries);
rpc listMarketplaceInstalledEntries(MarketplaceEntriesRequest) returns (MarketplaceInstalledEntries);
rpc installMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc uninstallMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc toggleMarketplaceLocalInstalledEntry(ToggleMarketplaceLocalInstalledEntryRequest) returns (MarketplaceLocalInstalledEntries);
rpc uninstallMarketplaceLocalInstalledEntry(MarketplaceLocalInstalledEntryRequest) returns (MarketplaceInstallResult);
}
message MarketplaceTag {
@@ -87,6 +89,10 @@ message ToggleMarketplaceLocalInstalledEntryRequest {
bool enabled = 2;
}
message MarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
}
message MarketplaceEntryRequest {
MarketplaceEntry entry = 1;
}
+4 -57
View File
@@ -397,7 +397,7 @@ message ModelsApiOptions {
optional bool azure_identity = 44;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -438,7 +438,7 @@ message ModelsApiOptions {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -568,59 +568,6 @@ message OcaCompatibleModelInfo {
optional string error = 2;
}
// API Provider enumeration
enum ApiProvider {
ANTHROPIC = 0;
OPENROUTER = 1;
BEDROCK = 2;
VERTEX = 3;
OPENAI = 4;
OLLAMA = 5;
LMSTUDIO = 6;
GEMINI = 7;
OPENAI_NATIVE = 8;
REQUESTY = 9;
TOGETHER = 10;
DEEPSEEK = 11;
QWEN = 12;
DOUBAO = 13;
MISTRAL = 14;
VSCODE_LM = 15;
CLINE = 16;
LITELLM = 17;
NEBIUS = 18;
FIREWORKS = 19;
ASKSAGE = 20;
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
ZAI = 31;
VERCEL_AI_GATEWAY = 32;
QWEN_CODE = 33;
DIFY = 34;
OCA = 35;
MINIMAX = 36;
HICAP = 37;
AIHUBMIX = 38;
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
POOLSIDE = 45;
V0 = 46;
XIAOMI = 47;
ZAI_CODING_PLAN = 49;
reserved 43, 44, 48;
reserved "OPENAI_CODEX_CLI", "OPENCODE", "KILO";
}
enum ApiFormat {
ANTHROPIC_CHAT = 0;
GEMINI_CHAT = 1;
@@ -760,7 +707,7 @@ message ModelsApiConfiguration {
optional string wandb_api_key = 87;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -806,7 +753,7 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
+7 -2
View File
@@ -237,8 +237,8 @@ message Settings {
optional string act_mode_nous_research_model_id = 121;
optional string act_mode_vercel_ai_gateway_model_id = 122;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
optional ApiProvider plan_mode_api_provider = 124;
optional ApiProvider act_mode_api_provider = 125;
optional string plan_mode_api_provider = 124;
optional string act_mode_api_provider = 125;
optional string hicap_model_id = 126;
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
@@ -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 {
+2
View File
@@ -12,6 +12,8 @@ option java_package = "bot.cline.proto";
service TaskService {
// Cancels the currently running task
rpc cancelTask(EmptyRequest) returns (Empty);
// Cancels a queued prompt by ID
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Clears the current task
+13
View File
@@ -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);
}
+54 -6
View File
@@ -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")
+14 -11
View File
@@ -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"))
+1 -1
View File
@@ -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
]
@@ -1,65 +1,16 @@
import { type CoreSettingsItem, createCoreSettingsService } from "@cline/core"
import { parseRemoteSkillEntries } from "@core/context/instructions/user-instructions/skills"
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { parseYamlFrontmatter } from "@/core/context/instructions/user-instructions/frontmatter"
import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
import { Controller } from ".."
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
const skills: SkillInfo[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skillMdPath = path.join(entryPath, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) continue
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
}
const frontmatter = result.data
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
if (!frontmatter.description || typeof frontmatter.description !== "string") continue
if (frontmatter.name !== entryName) continue
skills.push(
SkillInfo.create({
name: entryName,
description: frontmatter.description,
path: skillMdPath,
enabled: true, // Will be updated with toggle state
}),
)
} catch {
// Skip invalid skills
}
}
} catch {
// Directory read error, skip
}
return skills
function coreSkillToSkillInfo(skill: CoreSettingsItem): SkillInfo {
return SkillInfo.create({
name: skill.name,
description: skill.description ?? "",
path: skill.path,
enabled: skill.enabled !== false,
})
}
/**
@@ -70,33 +21,15 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
const globalSkills: SkillInfo[] = []
const localSkills: SkillInfo[] = []
if (primaryWorkspace) {
const scanDirs = getSkillsDirectoriesForScan(primaryWorkspace)
for (const dir of scanDirs) {
const skills = await scanSkillsDirectory(dir.path)
if (dir.source === "global") {
globalSkills.push(...skills)
} else {
localSkills.push(...skills)
}
}
} else {
const scanDirs = getSkillsDirectoriesForScan("")
for (const dir of scanDirs) {
if (dir.source !== "global") continue
const skills = await scanSkillsDirectory(dir.path)
globalSkills.push(...skills)
}
}
// Get global toggles and apply them
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
for (const skill of globalSkills) {
skill.enabled = globalToggles[skill.path] !== false
}
const settingsSnapshot = await createCoreSettingsService().list({
workspaceRoot: primaryWorkspace,
})
const globalSkills = settingsSnapshot.skills
.filter((skill) => skill.source === "global" || skill.source === "global-plugin")
.map(coreSkillToSkillInfo)
const localSkills = settingsSnapshot.skills
.filter((skill) => skill.source === "workspace" || skill.source === "workspace-plugin")
.map(coreSkillToSkillInfo)
// Add remote skills from remote config.
// Precedence: remote (enterprise) > disk-global (user) > project (workspace).
@@ -120,12 +53,6 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
)
}
// Get local toggles and apply them
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
for (const skill of localSkills) {
skill.enabled = localToggles[skill.path] !== false
}
return RefreshedSkills.create({
globalSkills,
localSkills,
@@ -0,0 +1,51 @@
import { afterEach, describe, it, mock } from "bun:test"
import * as assert from "assert"
import sinon from "sinon"
import type { Controller } from "../../index"
const installMarketplaceEntryFromCatalogStub: sinon.SinonStub = sinon.stub()
const marketplaceHelpersMock = () => ({
installMarketplaceEntryFromCatalog: installMarketplaceEntryFromCatalogStub,
})
mock.module("../marketplace-helpers", marketplaceHelpersMock)
mock.module("./marketplace-helpers", marketplaceHelpersMock)
describe("installMarketplaceEntry", () => {
afterEach(() => {
installMarketplaceEntryFromCatalogStub.reset()
})
it("reconciles the MCP hub after installing an MCP marketplace entry", async () => {
const { installMarketplaceEntry } = await import("../installMarketplaceEntry")
const reconcileMcpServersFromSettingsRPC = sinon.stub().resolves([])
const invalidateUserInstructionService = sinon.stub().resolves()
const controller = {
mcpHub: { reconcileMcpServersFromSettingsRPC },
invalidateUserInstructionService,
} as unknown as Controller
installMarketplaceEntryFromCatalogStub.resolves({
id: "chrome-devtools",
type: "mcp",
status: "installed",
})
await installMarketplaceEntry(controller, {
entry: {
id: "chrome-devtools",
type: "mcp",
name: "Chrome DevTools",
install: {
args: ["chrome-devtools", "--", "npx", "chrome-devtools-mcp@1.2.0"],
env: [],
},
tags: [],
tagObjects: [],
},
})
assert.equal(installMarketplaceEntryFromCatalogStub.callCount, 1)
assert.equal(reconcileMcpServersFromSettingsRPC.callCount, 1)
assert.equal(invalidateUserInstructionService.callCount, 0)
})
})
@@ -3,11 +3,18 @@ import type { Controller } from "../index"
import { installMarketplaceEntryFromCatalog } from "./marketplace-helpers"
export async function installMarketplaceEntry(
_controller: Controller,
controller: Controller,
request: MarketplaceEntryRequest,
): Promise<MarketplaceInstallResult> {
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
return installMarketplaceEntryFromCatalog(request.entry)
const result = await installMarketplaceEntryFromCatalog(request.entry)
if (request.entry.type === "mcp") {
await controller.mcpHub?.reconcileMcpServersFromSettingsRPC()
}
if (request.entry.type === "skill" || request.entry.type === "plugin") {
await controller.invalidateUserInstructionService()
}
return result
}
@@ -8,16 +8,23 @@ import {
discoverPluginModulePaths,
installMcpServer,
installPlugin,
isMarketplaceSkillInstalled,
type MarketplaceActionResult,
type MarketplaceEntryInput,
type MarketplacePrimitiveType,
parseMcpInstallArgs,
readGlobalSettings,
resolvePluginConfigSearchPaths,
setDisabledPlugin,
syncPluginMcpServersToSettings,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin,
} from "@cline/core"
import { deleteSkillFile } from "@core/controller/file/deleteSkillFile"
import { refreshSkills } from "@core/controller/file/refreshSkills"
import { toggleSkill } from "@core/controller/file/toggleSkill"
import { resolveActiveModelIdFromApiConfiguration } from "@core/controller/models/taskApiModel"
import { ToggleSkillRequest } from "@shared/proto/cline/file"
import { DeleteSkillRequest, ToggleSkillRequest } from "@shared/proto/cline/file"
import {
MarketplaceCatalog,
MarketplaceEntry,
@@ -25,6 +32,7 @@ import {
MarketplaceInstallResult,
MarketplaceLocalInstalledEntries,
MarketplaceLocalInstalledEntry,
MarketplaceLocalInstalledEntryRequest,
ToggleMarketplaceLocalInstalledEntryRequest,
} from "@shared/proto/cline/marketplace"
import { HostProvider } from "@/hosts/host-provider"
@@ -169,31 +177,9 @@ function isOfficialPluginInstalled(entry: MarketplaceEntry): boolean {
return existsSync(installPath)
}
function getSkillCandidates(entry: MarketplaceEntry): string[] {
const candidates = new Set([normalizeMatchValue(entry.id), normalizeMatchValue(entry.name)])
const args = getEntryArgs(entry)
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if ((arg === "--skill" || arg === "-s") && args[index + 1]) {
candidates.add(normalizeMatchValue(args[index + 1]))
index++
continue
}
const skillFilter = arg.split("@").at(1)
if (skillFilter) candidates.add(normalizeMatchValue(skillFilter))
}
candidates.delete("")
return [...candidates]
}
function isSkillInstalled(entry: MarketplaceEntry): boolean {
if (entry.type !== "skill") return false
return getSkillCandidates(entry).some((candidate) =>
[
join(resolveClineHome(), "skills", candidate, "SKILL.md"),
join(homedir(), ".agents", "skills", candidate, "SKILL.md"),
].some((path) => existsSync(path)),
)
return isMarketplaceSkillInstalled(toCoreMarketplaceEntry(entry))
}
export function listInstalledMarketplaceEntries(
@@ -382,6 +368,44 @@ export async function installMarketplaceEntryFromCatalog(entry: MarketplaceEntry
return installSkillMarketplaceEntry(entry, args)
}
function toCoreMarketplaceEntry(entry: MarketplaceEntry): MarketplaceEntryInput {
if (entry.type !== "mcp" && entry.type !== "skill" && entry.type !== "plugin") {
throw new Error(`Unsupported marketplace entry type: ${entry.type}`)
}
return {
id: entry.id,
type: entry.type as MarketplacePrimitiveType,
name: entry.name,
install: {
args: getEntryArgs(entry),
},
}
}
function toProtoMarketplaceInstallResult(result: MarketplaceActionResult): MarketplaceInstallResult {
return MarketplaceInstallResult.create({
id: result.id,
type: result.type,
status: result.status,
message: result.message,
output: result.output,
})
}
export async function uninstallMarketplaceEntryFromCatalog(
controller: Controller,
entry: MarketplaceEntry,
): Promise<MarketplaceInstallResult> {
const workspaceRoot = await getWorkspacePath()
const result = await uninstallCoreMarketplaceEntry(toCoreMarketplaceEntry(entry), {
deleteMcpServer: async (name) => {
await controller.mcpHub?.deleteServerRPC(name)
},
workspaceRoot,
})
return toProtoMarketplaceInstallResult(result)
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }
@@ -447,7 +471,6 @@ export async function listLocalMarketplaceInstalledEntries(controller: Controlle
type: "mcp",
name: server.name,
description: server.status,
path: server.config,
enabled: server.disabled !== true,
}),
)
@@ -530,6 +553,12 @@ export async function toggleLocalMarketplaceInstalledEntry(
): Promise<MarketplaceLocalInstalledEntries> {
const { entry, enabled } = request
if (!entry) throw new Error("Installed marketplace entry is required.")
if (entry.type === "mcp") {
const name = entry.name || entry.id
if (!name) throw new Error("MCP server name is required.")
await controller.mcpHub?.toggleServerDisabledRPC(name, !enabled)
return listLocalMarketplaceInstalledEntries(controller)
}
if (entry.type === "skill") {
await toggleSkill(
controller,
@@ -543,7 +572,64 @@ export async function toggleLocalMarketplaceInstalledEntry(
}
if (entry.type === "plugin") {
await togglePluginLocalEntry(controller, entry, enabled)
await controller.invalidateUserInstructionService()
return listLocalMarketplaceInstalledEntries(controller)
}
throw new Error(`Marketplace toggle is not supported for ${entry.type}.`)
}
export async function uninstallLocalMarketplaceInstalledEntry(
controller: Controller,
request: MarketplaceLocalInstalledEntryRequest,
): Promise<MarketplaceInstallResult> {
const { entry } = request
if (!entry) throw new Error("Installed marketplace entry is required.")
const name = entry.name || entry.id
if (entry.type === "mcp") {
if (!name) throw new Error("MCP server name is required.")
await controller.mcpHub?.deleteServerRPC(name)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
})
}
if (entry.type === "skill") {
if (entry.path?.startsWith("remote:")) {
throw new Error("Remote-managed skills cannot be uninstalled from Customize.")
}
if (!entry.path) throw new Error("Skill path is required for uninstall.")
await deleteSkillFile(
controller,
DeleteSkillRequest.create({
skillPath: entry.path,
isGlobal: entry.source === "global",
}),
)
await controller.invalidateUserInstructionService()
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${name || entry.id}.`,
})
}
if (entry.type === "plugin") {
const workspaceRoot = await getWorkspacePath()
const result = await uninstallPlugin({
name: entry.path ? undefined : name,
path: entry.path,
workspaceRoot,
})
await controller.invalidateUserInstructionService()
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
output: [`Path: ${result.installPath}`, ...result.removedPaths.map((path) => `Removed: ${path}`)].join("\n"),
})
}
throw new Error(`Marketplace uninstall is not supported for ${entry.type}.`)
}
@@ -0,0 +1,17 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { uninstallMarketplaceEntryFromCatalog } from "./marketplace-helpers"
export async function uninstallMarketplaceEntry(
controller: Controller,
request: MarketplaceEntryRequest,
): Promise<MarketplaceInstallResult> {
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
const result = await uninstallMarketplaceEntryFromCatalog(controller, request.entry)
if (request.entry.type === "skill" || request.entry.type === "plugin") {
await controller.invalidateUserInstructionService()
}
return result
}
@@ -0,0 +1,10 @@
import type { MarketplaceInstallResult, MarketplaceLocalInstalledEntryRequest } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { uninstallLocalMarketplaceInstalledEntry } from "./marketplace-helpers"
export async function uninstallMarketplaceLocalInstalledEntry(
controller: Controller,
request: MarketplaceLocalInstalledEntryRequest,
): Promise<MarketplaceInstallResult> {
return uninstallLocalMarketplaceInstalledEntry(controller, request)
}
@@ -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,20 @@
import { Empty, type StringRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Cancels a queued prompt for the active SDK session.
*
* @param controller The controller instance
* @param request The request containing the queued prompt ID
* @returns Empty response
*/
export async function cancelQueuedPrompt(controller: Controller, request: StringRequest): Promise<Empty> {
try {
await controller.cancelQueuedPrompt(request.value)
return Empty.create()
} catch (error) {
Logger.error("Error in cancelQueuedPrompt handler:", error)
throw error
}
}
@@ -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({})
}
+1
View File
@@ -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)
}
@@ -0,0 +1,68 @@
import assert from "node:assert/strict"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { VscodeTerminalManager } from "./VscodeTerminalManager"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
function createNeverEndingStream(): AsyncIterable<string> {
return {
async *[Symbol.asyncIterator]() {
await new Promise(() => {})
},
}
}
describe("VscodeTerminalManager", () => {
let sandbox: sinon.SinonSandbox
let manager: VscodeTerminalManager
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true })
manager = new VscodeTerminalManager()
})
afterEach(() => {
manager.disposeAll()
sandbox.restore()
})
it("returns after timing out a reused terminal cwd command", async () => {
const targetCwd = "/tmp/cline-target"
const executeCommandStub = sandbox.stub().returns({
read: () => createNeverEndingStream(),
})
const terminalInfo: TerminalInfo = {
id: 1,
busy: false,
lastCommand: "",
lastActive: Date.now(),
terminal: {
shellIntegration: {
cwd: vscode.Uri.file("/tmp/cline-original"),
executeCommand: executeCommandStub,
},
} as unknown as vscode.Terminal,
}
const getAllTerminalsStub = sandbox.stub(TerminalRegistry, "getAllTerminals").returns([terminalInfo])
let didResolve = false
const terminalPromise = manager.getOrCreateTerminal(targetCwd).then((terminal) => {
didResolve = true
return terminal
})
await sandbox.clock.tickAsync(4999)
assert.equal(didResolve, false)
await sandbox.clock.tickAsync(1)
const terminal = await terminalPromise
assert.equal(terminal, terminalInfo)
assert.equal(terminalInfo.busy, false)
assert.equal(terminalInfo.pendingCwdChange, undefined)
assert.equal(terminalInfo.cwdResolved, undefined)
assert.equal(getAllTerminalsStub.called, true)
assert.equal(executeCommandStub.calledOnceWith(`cd "${targetCwd}"`), true)
})
})
@@ -11,6 +11,9 @@ import { Logger } from "@/shared/services/Logger"
import { mergePromise, VscodeTerminalProcess } from "./VscodeTerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
const CWD_COMMAND_TIMEOUT_MS = 5000
const CWD_STATE_TIMEOUT_MS = 1000
/*
TerminalManager:
- Creates/reuses terminals
@@ -172,6 +175,57 @@ export class VscodeTerminalManager implements ITerminalManager {
return arePathsEqual(currentCwd, targetCwd)
}
private async drainCommandOutput(output: AsyncIterable<string>): Promise<void> {
for await (const _chunk of output) {
// Drain the stream so shell integration can report command completion.
}
}
// VS Code shell integration sometimes finishes the internal `cd` command without
// reporting completion through the execution stream. Timeout this setup step so
// the user's actual command is still sent instead of leaving the chat stuck.
private async runCwdChangeCommand(terminalInfo: TerminalInfo, cwd: string): Promise<boolean> {
const command = `cd "${cwd}"`
const shellIntegration = terminalInfo.terminal.shellIntegration
if (!shellIntegration?.executeCommand) {
terminalInfo.terminal.sendText(command, true)
Logger.warn(
`[TerminalManager] Shell integration executeCommand is unavailable while changing terminal ${terminalInfo.id} cwd. Proceeding after ${CWD_COMMAND_TIMEOUT_MS}ms.`,
)
await new Promise((resolve) => setTimeout(resolve, CWD_COMMAND_TIMEOUT_MS))
return true
}
let timeout: NodeJS.Timeout | undefined
let didTimeOut = false
try {
const execution = shellIntegration.executeCommand(command)
await Promise.race([
this.drainCommandOutput(execution.read()),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
didTimeOut = true
Logger.warn(
`[TerminalManager] Timed out waiting ${CWD_COMMAND_TIMEOUT_MS}ms for terminal ${terminalInfo.id} to run cd "${cwd}". Proceeding with requested command.`,
)
resolve()
}, CWD_COMMAND_TIMEOUT_MS)
}),
])
} catch (error) {
Logger.warn(`[TerminalManager] Failed to observe terminal ${terminalInfo.id} cwd command completion`, error)
return true
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
return didTimeOut
}
runCommand(terminalInfo: ITerminalInfo, command: string): ITerminalProcessResultPromise {
// Cast to VSCode-specific TerminalInfo for internal use
// Using unknown as intermediate cast due to structural differences between ITerminal and vscode.Terminal
@@ -285,43 +339,34 @@ export class VscodeTerminalManager implements ITerminalManager {
(t) => !t.busy && VscodeTerminalManager.effectiveShellPath(t.shellPath) === effectiveExpected,
)
if (availableTerminal) {
availableTerminal.busy = true
// Set up promise and tracking for CWD change
const cwdPromise = new Promise<void>((resolve, reject) => {
availableTerminal.pendingCwdChange = cwd
availableTerminal.cwdResolved = { resolve, reject }
})
// Navigate back to the desired directory
// Cast to ITerminalInfo for interface compatibility
const cdProcess = this.runCommand(availableTerminal as unknown as ITerminalInfo, `cd "${cwd}"`)
try {
const didCwdCommandTimeOut = await this.runCwdChangeCommand(availableTerminal, cwd)
// Wait for the cd command to complete before proceeding
await cdProcess
// Add a small delay to ensure terminal is ready after cd
await new Promise((resolve) => setTimeout(resolve, 100))
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
if (availableTerminal.cwdResolved) {
availableTerminal.cwdResolved.resolve()
// Add a small delay to ensure terminal is ready after cd
if (!didCwdCommandTimeOut) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
if (availableTerminal.cwdResolved) {
availableTerminal.cwdResolved.resolve()
}
} else if (!didCwdCommandTimeOut) {
await Promise.race([cwdPromise, new Promise((resolve) => setTimeout(resolve, CWD_STATE_TIMEOUT_MS))])
}
} finally {
availableTerminal.pendingCwdChange = undefined
availableTerminal.cwdResolved = undefined
} else {
try {
// Wait with a timeout for state change event to resolve
await Promise.race([
cwdPromise,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`CWD timeout: Failed to update to ${cwd}`)), 1000),
),
])
} catch (_err) {
// Clear pending state on timeout
availableTerminal.pendingCwdChange = undefined
availableTerminal.cwdResolved = undefined
}
availableTerminal.busy = false
}
this.terminalIds.add(availableTerminal.id)
// Cast to ITerminalInfo for interface compatibility
+10
View File
@@ -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")
+151 -16
View File
@@ -9,8 +9,8 @@ import * as path from "node:path"
import {
createUserInstructionConfigService,
getProviderAuthStorageId,
resolveDefaultMcpSettingsPath,
type PreparedRemoteConfigCoreIntegration,
resolveDefaultMcpSettingsPath,
type SessionHistoryRecord,
setTelemetryOptOutGlobally,
type UserInstructionConfigService,
@@ -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
@@ -610,6 +646,15 @@ export class Controller {
}
}
async invalidateUserInstructionService(): Promise<void> {
const userInstructionServicePromise = this.userInstructionService
this.userInstructionService = undefined
this.userInstructionServiceRoot = undefined
if (userInstructionServicePromise) {
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
}
}
async dispose(): Promise<void> {
this.providerConfigStoreSubscription.dispose()
// Clear the remote config timer to prevent stale fetches
@@ -619,11 +664,7 @@ export class Controller {
}
await this.setRemoteConfigCoreIntegration(undefined)
this.isDisposed = true
const userInstructionServicePromise = this.userInstructionService
this.userInstructionService = undefined
if (userInstructionServicePromise) {
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
}
await this.invalidateUserInstructionService()
this.messages.cancelPendingSave()
// Clear MCP tool list change callback before disposing McpHub
this.mcpHub?.clearToolListChangeCallback()
@@ -666,7 +707,11 @@ export class Controller {
this.userInstructionService = (async () => {
const service = createUserInstructionConfigService({
workflows: { workspacePath: workspaceRoot },
skills: { workspacePath: workspaceRoot },
skills: {
workspacePath: workspaceRoot,
includePluginSkills: true,
cwd: workspaceRoot,
},
rules: { workspacePath: workspaceRoot },
})
// start() runs the initial scan; await so the snapshot is populated
@@ -809,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,
})
}
/**
@@ -988,6 +1100,29 @@ export class Controller {
stubWarn("cancelBackgroundCommand")
}
async cancelQueuedPrompt(promptId: string): Promise<void> {
const trimmedPromptId = promptId.trim()
if (!trimmedPromptId) {
Logger.warn("[SdkController] cancelQueuedPrompt: Missing prompt id")
return
}
const activeSession = this.sessions.getActiveSession()
if (!activeSession) {
Logger.warn("[SdkController] cancelQueuedPrompt: No active session")
return
}
const result = await activeSession.sdkHost.pendingPrompts("delete", {
sessionId: activeSession.sessionId,
promptId: trimmedPromptId,
})
if (!result.removed) {
Logger.warn(`[SdkController] cancelQueuedPrompt: Prompt not found: ${trimmedPromptId}`)
}
await this.postStateToWebview()
}
/**
* Manually compact (condense) the active task's conversation. Triggered by
* the compact button and the `/compact` (alias `/smol`) slash command.
@@ -1108,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
}
@@ -1210,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 })
})
@@ -333,6 +336,21 @@ describe("buildSessionConfig", () => {
expect(mocks.providerSettingsManager.getProviderSettings).not.toHaveBeenCalled()
})
it("resolves OpenAI Compatible API keys from migrated SDK provider settings", () => {
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
} as any
})
expect(resolveApiKey("openai", {} as any)).toBe("migrated-openai-compatible-key")
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("openai-compatible")
})
it("resolves OpenAI Codex through the shared OAuth provider registry", async () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue({
provider: "openai-codex",
@@ -363,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
@@ -636,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" })
+12 -4
View File
@@ -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 */
@@ -133,6 +136,10 @@ function hasStaleDisabledReasoningFields(reasoning: ProviderReasoningSettings |
return reasoning?.enabled === false && (reasoning.effort !== undefined || reasoning.budgetTokens !== undefined)
}
function providerSettingsProviderId(providerId: string): string {
return toSdkProviderId(providerId)
}
/**
* Convert SDK provider-level reasoning settings into the SDK session fields that
* are actually forwarded as model options. Keep `thinking` and
@@ -160,7 +167,7 @@ export function normalizeProviderReasoningSettings(reasoning: ProviderReasoningS
function resolveProviderReasoningConfig(providerId: string): SessionReasoningConfig {
try {
const manager = getProviderSettingsManager(resolveDataDir())
const settings = manager.getProviderSettings(providerId)
const settings = manager.getProviderSettings(providerSettingsProviderId(providerId))
if (!settings) {
return {}
}
@@ -333,7 +340,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
// hardcoding provider exceptions.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
if (apiKey) {
return apiKey
}
@@ -359,7 +366,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
// startup.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
if (apiKey) {
return apiKey
}
@@ -649,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
@@ -693,7 +701,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
? {
compaction: {
enabled: true,
strategy: "basic",
strategy: compactionStrategy,
},
}
: {}),
@@ -94,9 +94,28 @@ describe("buildEffectiveProviderConfig", () => {
})
})
it("reads migrated OpenAI Compatible settings from the SDK provider id", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
},
})
expect(buildEffectiveProviderConfig(parseProviderId("openai"))).toEqual({
providerId: parseProviderId("openai"),
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
})
})
it("reads normalized nousResearch API key from StateManager", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({ nousresearch: { provider: "nousresearch", apiKey: "provider-nous-key" } })
mocks.setProviderSettings({ nousResearch: { provider: "nousResearch", apiKey: "provider-nous-key" } })
mocks.setApiConfiguration({ nousResearchApiKey: "state-nous-key" })
expect(buildEffectiveProviderConfig(parseProviderId("nousResearch"))).toEqual({
@@ -2,6 +2,7 @@ import type { ApiConfiguration } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { AwsProviderConfig, EffectiveProviderConfig, GcpProviderConfig, ProviderId } from "./contracts"
import { toSdkProviderId } from "./sdk-provider-id"
type AuthConfig = NonNullable<EffectiveProviderConfig["auth"]>
type ExtrasConfig = NonNullable<EffectiveProviderConfig["extras"]>
@@ -192,7 +193,7 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
function readProviderSettings(providerId: ProviderId): ConfigParts {
try {
const settings: unknown = getProviderSettingsManager().getProviderSettings(providerId)
const settings: unknown = getProviderSettingsManager().getProviderSettings(toSdkProviderId(providerId))
if (!isPlainRecord(settings)) {
return {}
}
@@ -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>
+118 -2
View File
@@ -181,13 +181,129 @@ describe("createProviderConfigStore", () => {
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getSavedProviderSettings("nousresearch")).toMatchObject({
provider: "nousresearch",
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
apiKey: "nous-key",
model: "nousresearch/hermes-4-70b",
})
})
it("reads migrated OpenAI Compatible settings from the SDK provider id", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
expect(store.read(providerId)).toEqual({
providerId,
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
})
})
it("writes OpenAI Compatible settings under the SDK provider id", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.write(providerId, {
apiKey: "openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
})
})
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
store.commitSelection(providerId, "act", selection)
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "gpt-oss-120b",
})
})
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "plan-openai-model",
planModeOpenAiModelInfo: modelInfoA,
actModeOpenAiModelId: "act-openai-model",
actModeOpenAiModelInfo: modelInfoB,
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "act-openai-model",
})
})
it("mirrors OpenAI Compatible selections to both modes when separate models are disabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "plan")).toEqual(selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "shared-openai-model",
planModeOpenAiModelInfo: modelInfoA,
actModeOpenAiModelId: "shared-openai-model",
actModeOpenAiModelInfo: modelInfoA,
})
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
model: "shared-openai-model",
})
})
it("writes Z.AI Coding Plan API keys only to provider-specific settings", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ zaiApiKey: "shared-zai-key" })
+7 -2
View File
@@ -127,6 +127,10 @@ function providerForStorage(providerId: ProviderId): ApiProvider | undefined {
return key as ApiProvider
}
function providerSettingsProviderId(providerId: ProviderId): string {
return toSdkProviderId(providerId)
}
function memoryKey(providerId: ProviderId, mode: Mode): string {
return `${providerId}:${mode}`
}
@@ -280,12 +284,13 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
const settings = getProviderSettingsManager().getProviderSettings(providerId)
const settings = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))
return isRecord(settings) ? settings : {}
}
function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsRecord): void {
getProviderSettingsManager().saveProviderSettings({ provider: providerId, ...next }, { setLastUsed: false })
const provider = providerSettingsProviderId(providerId)
getProviderSettingsManager().saveProviderSettings({ ...next, provider }, { setLastUsed: false })
}
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
@@ -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
}
}
@@ -21,7 +21,12 @@ describe("SdkFollowupCoordinator", () => {
await coordinator.askResponse("yes", undefined, undefined, "yesButtonClicked")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("yes", "yesButtonClicked")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"yes",
"yesButtonClicked",
undefined,
undefined,
)
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
})
@@ -122,6 +127,8 @@ describe("SdkFollowupCoordinator", () => {
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"do the next thing after this",
"messageResponse",
undefined,
undefined,
)
expect(options.waitForPendingModeRebuild).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
@@ -204,7 +211,12 @@ describe("SdkFollowupCoordinator", () => {
await coordinator.askResponse("just give me an answer", undefined, undefined, "messageResponse")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("just give me an answer", "messageResponse")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"just give me an answer",
"messageResponse",
undefined,
undefined,
)
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
@@ -58,7 +58,7 @@ export class SdkFollowupCoordinator {
return
}
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse)) {
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse, images, files)) {
return
}
@@ -103,8 +103,9 @@ describe("SdkInteractionCoordinator", () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const recordApprovedToolMessage = vi.fn()
const recordDeniedToolApproval = vi.fn()
const messages = new SdkMessageCoordinator({ getTask: () => task })
const coordinator = new SdkInteractionCoordinator({
messages: new SdkMessageCoordinator({ getTask: () => task }),
messages,
getSessionId: () => "session-123",
postStateToWebview: vi.fn().mockResolvedValue(undefined),
recordApprovedToolMessage,
@@ -125,9 +126,17 @@ describe("SdkInteractionCoordinator", () => {
const clineMessages = task.messageStateHandler.getClineMessages()
expect(clineMessages[0]).toMatchObject({ type: "ask", ask: "command", text: "npm test" })
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked")).toBe(true)
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked", ["image.png"], ["a.ts"])).toBe(true)
expect(recordApprovedToolMessage).not.toHaveBeenCalled()
expect(recordDeniedToolApproval).toHaveBeenCalledWith("tool-call", "execute_command", "too risky")
expect(task.messageStateHandler.getClineMessages()[1]).toMatchObject({
type: "say",
say: "user_feedback",
text: "too risky",
images: ["image.png"],
files: ["a.ts"],
partial: false,
})
await expect(approvalPromise).resolves.toEqual({ approved: false, reason: "too risky" })
})
@@ -188,6 +197,7 @@ describe("SdkInteractionCoordinator", () => {
approved: false,
reason: DEFAULT_TOOL_APPROVAL_DENIAL_REASON,
})
expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)
expect(recordDeniedToolApproval).toHaveBeenCalledWith(
"tool-call",
"fetch_web_content",
@@ -127,7 +127,12 @@ export class SdkInteractionCoordinator {
})
}
resolvePendingToolApproval(prompt: string | undefined, responseType: ClineAskResponse | undefined): boolean {
resolvePendingToolApproval(
prompt: string | undefined,
responseType: ClineAskResponse | undefined,
images?: string[],
files?: string[],
): boolean {
if (!this.pendingToolApprovalResolve) {
return false
}
@@ -155,6 +160,21 @@ export class SdkInteractionCoordinator {
// On rejection the agent receives the denial and continues; the SDK drives the next phase.
this.options.setTurnPhase?.("streaming")
const denialReason = prompt || DEFAULT_TOOL_APPROVAL_DENIAL_REASON
if (!approved && (prompt?.trim() || images?.length || files?.length)) {
const userMessage: ClineMessage = {
ts: this.nextMessageTs(),
type: "say",
say: "user_feedback",
text: prompt ?? "",
images,
files,
partial: false,
}
this.options.messages.appendAndEmit([userMessage], {
type: "status",
payload: { sessionId: this.options.getSessionId(), status: "running" },
})
}
if (!approved && pendingMessage) {
this.options.recordDeniedToolApproval?.(pendingMessage.toolCallId, pendingMessage.toolName, denialReason)
}
@@ -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)
@@ -3,6 +3,10 @@ import { describe, expect, it } from "vitest"
import { isToolAutoApproved } from "./sdk-tool-policies"
describe("isToolAutoApproved", () => {
it("does not auto-approve command tools by default", () => {
expect(isToolAutoApproved("run_commands", DEFAULT_AUTO_APPROVAL_SETTINGS)).toBe(false)
})
it("uses executeSafeCommands as the single command approval flag", () => {
const settings = {
...DEFAULT_AUTO_APPROVAL_SETTINGS,
@@ -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.
*/
@@ -11,7 +11,7 @@ describe("ClineError", () => {
it("should return Entitlement for the SDK ClinePass subscription message", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
@@ -19,7 +19,7 @@ describe("ClineError", () => {
it("should return Entitlement for the SDK ClinePass subscription message with a different app URL", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-100&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-8OFF&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
+9
View File
@@ -1267,6 +1267,15 @@ export class McpHub {
await this.notifyWebviewOfServerChanges()
}
async reconcileMcpServersFromSettingsRPC(): Promise<McpServer[]> {
const settings = await this.readPostWriteMcpSettings()
await this.updateServerConnectionsRPC(settings.mcpServers as Record<string, McpServerConfig>)
await this.notifyWebviewOfServerChanges()
const serverOrder = Object.keys(settings.mcpServers || {})
return this.getSortedMcpServers(serverOrder)
}
async getLatestMcpServersRPC(): Promise<McpServer[]> {
const settings = await this.readAndValidateMcpSettingsFile()
if (!settings) {

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