mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fd2d38c99 | |||
| f1c81047eb | |||
| 78c97ee124 | |||
| 527f2af2e3 | |||
| 0056b08300 | |||
| 035cce0ac5 | |||
| ef76b1b977 | |||
| 0e1da42925 | |||
| dcf78364ca | |||
| 4c03c79b4b | |||
| 9c61adbd87 | |||
| 5919b0fa09 | |||
| 9312f62e09 | |||
| b4e3cb1d2b | |||
| 42163a1229 | |||
| 7337926c6e | |||
| 9066e6c522 | |||
| e021e0777b | |||
| c9d51f6332 | |||
| ffe446d55d | |||
| c446ca7def | |||
| 41418bc201 | |||
| 48a4016077 | |||
| 7e2bc6488f | |||
| 6ab316ad82 | |||
| 361f35841a | |||
| e10b6e68dd | |||
| 713631e1ea | |||
| 0194c98b0f | |||
| cbd73d2c1f | |||
| 25fd83ac4e | |||
| 86c61b96f6 | |||
| 0cba47c145 | |||
| ead0f9373a | |||
| 1ff2fe7887 | |||
| 6b2c9213d0 | |||
| 5ee67a7f20 | |||
| 2a9057f184 | |||
| ae3234212c | |||
| 24c489eceb | |||
| b3ccece14e | |||
| 7c4cf02657 | |||
| 2c6de4c6a6 | |||
| 4d72d4e6f4 | |||
| 82c8f28dae | |||
| b31bed5b60 | |||
| ec69289e40 | |||
| fed81c55b1 | |||
| 2fc24d30de | |||
| 178d3db1ab | |||
| 685103b193 | |||
| 015ece10b6 | |||
| 155c38f7c8 | |||
| 4619bb78bc | |||
| 7799128c42 | |||
| 851d3857c2 | |||
| 9536a39cb7 | |||
| 63a78519a3 | |||
| 91b1ecd354 | |||
| 1e6c2c0286 | |||
| 571f2fbde8 | |||
| de2094f27f | |||
| 8f43de8c2a | |||
| 16a3895134 | |||
| 1deb0f4c52 | |||
| 5b71d48b31 | |||
| a37cb01c8a | |||
| aa7b06f5c9 | |||
| 2310ce56ae | |||
| 69976c2a23 | |||
| f8d3220c86 | |||
| 161682d858 | |||
| a7f4d4778b | |||
| 2f00a50fed | |||
| 6313920e71 | |||
| f2eb188eed | |||
| f8c81f1148 | |||
| 5360a2af4f | |||
| 963fac8f6d | |||
| a89795fefb | |||
| 7addf92aea | |||
| 99765931db | |||
| 00672f2fd0 | |||
| efc5d3ade5 | |||
| a1b596c3ef | |||
| 7e917746a0 |
@@ -1,294 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,9 +1,6 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
@@ -27,10 +27,6 @@ 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
|
||||
@@ -106,13 +102,7 @@ jobs:
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
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."
|
||||
echo "Using existing tag '$TAG'."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
@@ -180,60 +170,6 @@ 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 }}
|
||||
@@ -274,6 +210,24 @@ 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:
|
||||
|
||||
@@ -85,10 +85,3 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
"sdk/packages/core/src/auth/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-telemetry-doc-update",
|
||||
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
|
||||
"scope": [
|
||||
"sdk/packages/core/src/services/telemetry/core-events.ts"
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
|
||||
},
|
||||
{
|
||||
"path": "DOC.md",
|
||||
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
|
||||
},
|
||||
{
|
||||
"path": "sdk/ARCHITECTURE.md",
|
||||
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
|
||||
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "sdk/AGENTS.md",
|
||||
|
||||
+17
-20
@@ -36,11 +36,8 @@ event names. It exports:
|
||||
|
||||
1. Add the constant to `CORE_TELEMETRY_EVENTS`
|
||||
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
|
||||
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
|
||||
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
|
||||
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
3. Update the Event Catalog section in `DOC.md`
|
||||
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
@@ -85,7 +82,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
|
||||
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
|
||||
config dir.
|
||||
|
||||
The canonical pattern is in `apps/cli/src/main.ts`:
|
||||
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
|
||||
|
||||
```ts
|
||||
if (configDir) setClineDir(configDir);
|
||||
@@ -93,18 +90,18 @@ setHomeDir(homedir());
|
||||
captureCliExtensionActivated(); // <-- after dir overrides
|
||||
```
|
||||
|
||||
## Hub Daemon Telemetry
|
||||
## Hub Daemon Metadata Forwarding
|
||||
|
||||
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
|
||||
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
|
||||
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
|
||||
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
|
||||
metadata into the daemon argv so the daemon can reconstruct an equivalent
|
||||
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
|
||||
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
```
|
||||
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
|
||||
```
|
||||
|
||||
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
|
||||
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
@@ -123,10 +120,10 @@ canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
|
||||
On VS Code, all callers go through the lazy `telemetryService` proxy in
|
||||
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
|
||||
use. Do not let individual controllers construct their own `ITelemetryService` — that
|
||||
fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
On VS Code, the telemetry handle is built **once** in `activate()`
|
||||
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
|
||||
command, and daemon spawn payload. Do not let individual controllers construct their own
|
||||
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
|
||||
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
|
||||
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
|
||||
|
||||
Vendored
+1
-14
@@ -22,24 +22,11 @@
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
"biome.enabled": true,
|
||||
"biome.requireConfiguration": true,
|
||||
"prettier.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
|
||||
Vendored
+9
-29
@@ -68,7 +68,7 @@
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -89,7 +89,7 @@
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -114,16 +114,16 @@
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"message": 1
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -169,8 +169,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -209,8 +208,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -313,24 +311,6 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk:debug",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CLINE_SOURCEMAPS": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@@ -1,44 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -149,7 +149,7 @@ Toggle between Plan mode and Act mode. In Plan mode, Cline explores your codebas
|
||||
|
||||
## Rules and Skills
|
||||
|
||||
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
|
||||
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
|
||||
|
||||
## Works With Every Model
|
||||
|
||||
@@ -158,10 +158,10 @@ Cline is not locked to a single AI provider. Use whichever model fits your workf
|
||||
| Provider | Models |
|
||||
|----------|--------|
|
||||
| Anthropic | Claude Opus, Sonnet, Haiku |
|
||||
| OpenAI | GPT series models |
|
||||
| Google | Gemini series models |
|
||||
| OpenAI | GPT series model |
|
||||
| Google | Gemini series model |
|
||||
| OpenRouter | 200+ models from any provider |
|
||||
| Vercel AI Gateway | Route to many providers through one gateway |
|
||||
| Vercel AI Gateway | Models through Vercel AI Gateway |
|
||||
| AWS Bedrock | Claude, Llama, and more |
|
||||
| Azure / GCP Vertex | All hosted models |
|
||||
| Cerebras / Groq | Fast inference models |
|
||||
|
||||
@@ -1,121 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
- Fixed provider config not reloading when switching models
|
||||
- Fixed auto-update failing to detect Bun global installs after symlink resolution
|
||||
- Fixed unexpected logouts caused by transient network or server errors during token refresh
|
||||
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
|
||||
- Session id is now preserved when continuing within the same session
|
||||
- Hardened context compaction budget handling
|
||||
|
||||
## 3.0.39
|
||||
|
||||
- You can now select Cline free models on the ClinePass provider in the model picker
|
||||
- Removed the retired ClinePass GLM 5.1 model
|
||||
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
|
||||
- `str_replace` edits now report accurate diffs
|
||||
- Fixed context compaction so canonical session history is preserved
|
||||
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
|
||||
- Cline provider requests now send versioned client-identity headers
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
|
||||
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
|
||||
- Polished the status bar usage display and ClinePass model name
|
||||
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
|
||||
- The thinking-level picker now defaults its cursor to Medium instead of Off
|
||||
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
|
||||
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
|
||||
|
||||
## 3.0.37
|
||||
|
||||
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
|
||||
- Plan/act mode switches are now visible to the model, so it knows when you change modes mid-session
|
||||
- Fixed plan/act mode notices being dropped from prompts sent to the model
|
||||
- Fixed a race where switching modes in an empty session could trigger an unexpected restart
|
||||
|
||||
## 3.0.36
|
||||
|
||||
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
|
||||
|
||||
## 3.0.35
|
||||
|
||||
- ClinePass is now enabled for all CLI users
|
||||
- Recover missing interactive sessions when reading messages
|
||||
- Format structured commands in history export
|
||||
- Add the subscription promo code when linking to the dashboard subscription page
|
||||
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
|
||||
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
|
||||
- Advertise run commands as shell strings (from SDK v0.0.55)
|
||||
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
|
||||
|
||||
## 3.0.34
|
||||
|
||||
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
|
||||
- Improved the wording of the ClinePass onboarding step.
|
||||
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
|
||||
|
||||
## 3.0.33
|
||||
|
||||
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
|
||||
- Hide the ClinePass promo for users who already have a ClinePass subscription
|
||||
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
|
||||
|
||||
## 3.0.32
|
||||
|
||||
- Improved the ClinePass onboarding experience
|
||||
- Added an intermediate step before going to ClinePass model selection
|
||||
- Made the ClinePass subscription screen selectable
|
||||
- Promoted ClinePass in the startup notice
|
||||
- Used "ClinePass" as one word consistently and refined the provider UI copy
|
||||
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
|
||||
|
||||
## 3.0.31
|
||||
|
||||
- Show when request cost is covered by your Cline subscription
|
||||
- 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
|
||||
- Added organization-specific error messages
|
||||
- Added SAP AI Core provider support
|
||||
- Refreshed the model catalog with the latest provider models
|
||||
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
|
||||
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
|
||||
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
|
||||
- Threaded proxy/CA-aware networking into the inference path
|
||||
- Persisted Bedrock settings to providers.json
|
||||
- Normalized JSON-like tool inputs by schema for more reliable tool calls
|
||||
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
|
||||
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ const result = await Bun.build({
|
||||
},
|
||||
env: "OTEL_*",
|
||||
banner:
|
||||
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
|
||||
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
});
|
||||
|
||||
if (result.logs.length > 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.40",
|
||||
"version": "3.0.27",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -511,7 +511,6 @@ export class AcpAgent implements Agent {
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
const cwd = session.cwd || process.cwd();
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
// Resolve credentials: env vars take precedence, then session provider.
|
||||
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
|
||||
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
@@ -520,7 +519,6 @@ export class AcpAgent implements Agent {
|
||||
providerId,
|
||||
mode: session.currentMode,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
|
||||
return {
|
||||
providerId,
|
||||
@@ -539,23 +537,7 @@ export class AcpAgent implements Agent {
|
||||
enableAgentTeams: false,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: {
|
||||
name: "cline-acp",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
workspaceName: cwd,
|
||||
ide: "Terminal Shell",
|
||||
platform: process.platform,
|
||||
},
|
||||
},
|
||||
workspaceRoot: resolveWorkspaceRoot(cwd),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,45 +313,6 @@ 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 = {
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
installMcpServer: vi.fn((options) => {
|
||||
const { name, transport, warnings } =
|
||||
actual.buildMcpInstallTransport(options);
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
transport,
|
||||
warnings,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
@@ -110,52 +88,6 @@ describe("mcp install command", () => {
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("builds direct stdio installs without shell-joining args", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds direct remote installs with headers and placeholder warnings", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
headers: ["Authorization: Bearer <token>"],
|
||||
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Extra": "yes",
|
||||
},
|
||||
},
|
||||
warnings: [
|
||||
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
@@ -192,11 +124,11 @@ describe("mcp install command", () => {
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating wizard install arguments", async () => {
|
||||
it("checks for TTY before validating install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
@@ -207,65 +139,7 @@ describe("mcp install command", () => {
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly with --yes without requiring a TTY", async () => {
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(installMcpServer).toHaveBeenCalledWith({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints direct install JSON with --yes --json", async () => {
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
targetArgs: ["node", "server.js"],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
|
||||
name: "fs",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
import {
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions extends CoreMcpInstallOptions {
|
||||
export interface McpInstallOptions {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
json?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export interface McpInstallDirectResult {
|
||||
name: string;
|
||||
status: "installed";
|
||||
transport: McpServerTransportConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
): McpAddDefaults["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
@@ -106,18 +91,6 @@ export function buildMcpInstallDefaults(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function installMcpServerDirect(
|
||||
options: McpInstallOptions,
|
||||
): McpInstallDirectResult {
|
||||
const result: McpInstallResult = installMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
transport: result.transport,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
@@ -131,23 +104,11 @@ export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (options.yes) {
|
||||
const result = installMcpServerDirect(options);
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
|
||||
for (const warning of result.warnings) {
|
||||
options.io?.writeErr(warning);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
|
||||
+1180
-24
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
|
||||
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
|
||||
)
|
||||
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
|
||||
.option(
|
||||
|
||||
@@ -101,22 +101,6 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
@@ -118,12 +118,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
|
||||
// them to ~/.bun/install/global/node_modules/..., so match both.
|
||||
if (
|
||||
scriptPath.includes("/.bun/bin") ||
|
||||
scriptPath.includes("/.bun/install/global/")
|
||||
) {
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
|
||||
@@ -1,2 +1,36 @@
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
export type ConnectorCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
name: "discord",
|
||||
description:
|
||||
"Discord interactions and gateway bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
description: "Google Chat webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
description: "Linear webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
description: "Bridge Telegram bot messages into RPC chat sessions",
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
|
||||
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
@@ -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: true,
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
@@ -63,7 +64,8 @@ export async function buildConnectorStartRequest(input: {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @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(
|
||||
@@ -13,29 +10,10 @@ 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);
|
||||
|
||||
@@ -44,24 +22,25 @@ export function MigrationNoticeContent(
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
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.
|
||||
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>
|
||||
</text>
|
||||
<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 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>
|
||||
</box>
|
||||
<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>
|
||||
<text fg={palette.muted}>Press Esc to close</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClineCliMigrationNotice,
|
||||
markClineCliMigrationNoticeShown,
|
||||
resolveCliNoticeStatePath,
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider,
|
||||
} from "./notice";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -33,25 +26,8 @@ describe("migration notice", () => {
|
||||
it("returns the notice for a fresh data dir", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
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",
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
|
||||
"Welcome to the new Cline CLI",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -70,7 +46,7 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -80,56 +56,18 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_DISABLE_MIGRATION_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_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -140,7 +78,7 @@ describe("migration notice", () => {
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain("cline-cli-cline-pass-intro");
|
||||
expect(rawState).toContain("cline-cli-tui-default");
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,15 @@ 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-cline-pass-intro";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
|
||||
const NOTICE_ID = "cline-cli-tui-default";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
|
||||
|
||||
export interface CliMigrationNotice {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CliMigrationNoticeOptions {
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
interface CliNoticeState {
|
||||
shown: Record<string, boolean>;
|
||||
}
|
||||
@@ -53,19 +49,6 @@ 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 {
|
||||
@@ -75,29 +58,20 @@ 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 = isForceNoticeEnabled(env);
|
||||
const forceNotice = env[FORCE_NOTICE_ENV]?.trim() === "1";
|
||||
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: "Try ClinePass",
|
||||
title: "Welcome to the new Cline CLI",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+45
-266
@@ -1,9 +1,6 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CliMigrationNotice,
|
||||
CliMigrationNoticeOptions,
|
||||
} from "./kanban-migration/notice";
|
||||
import type { CliMigrationNotice } 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(() => ({
|
||||
@@ -62,13 +59,9 @@ const dashboardMocks = vi.hoisted(() => ({
|
||||
runDashboardCommand: vi.fn(),
|
||||
}));
|
||||
const migrationNoticeMocks = vi.hoisted(() => ({
|
||||
getClineCliMigrationNotice: vi.fn<
|
||||
(
|
||||
dataDir?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
options?: CliMigrationNoticeOptions,
|
||||
) => CliMigrationNotice | undefined
|
||||
>(() => undefined),
|
||||
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
|
||||
() => undefined,
|
||||
),
|
||||
markClineCliMigrationNoticeShown: vi.fn(),
|
||||
}));
|
||||
const updateMocks = vi.hoisted(() => ({
|
||||
@@ -122,7 +115,6 @@ const telemetryMocks = vi.hoisted(() => ({
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
setCliFeatureFlagsAccountContext: vi.fn(),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
@@ -158,9 +150,8 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
});
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", async () => {
|
||||
vi.mock("@cline/core", () => {
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
@@ -188,8 +179,7 @@ vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground:
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground,
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
setCliFeatureFlagsAccountContext:
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
}));
|
||||
@@ -268,7 +258,6 @@ 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();
|
||||
@@ -418,7 +407,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("does not load interactive runtime for single-prompt mode", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -428,30 +417,6 @@ 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")
|
||||
@@ -465,7 +430,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Unknown command or unquoted prompt: hello world",
|
||||
"Unknown command or extra arguments: hello world",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
@@ -509,7 +474,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", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -518,7 +483,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/cline-worktree",
|
||||
workspaceRoot: "/tmp/cline-worktree",
|
||||
@@ -641,8 +606,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("passes the migration notice marker into interactive mode", async () => {
|
||||
const notice = {
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
id: "cline-cli-tui-default",
|
||||
title: "Welcome to the new Cline CLI",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
@@ -673,37 +638,6 @@ 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");
|
||||
@@ -793,7 +727,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("uses the bundled catalog path for single-prompt runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -984,7 +918,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
|
||||
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
@@ -1003,91 +937,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.refreshCliFeatureFlagsInBackground.mock
|
||||
.invocationCallOrder[0],
|
||||
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
|
||||
// CLINE-2406: when persisted Cline auth includes an accountId, the
|
||||
// runtime path must call identifyTelemetryAccount(accountContext) so
|
||||
// subsequent task.* and workspace.* events carry user_id.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "usr-abc-123",
|
||||
provider: "cline",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
|
||||
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
|
||||
// identifyTelemetryAccount should not be called from the runtime path.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
// no auth / no accountId
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
|
||||
// CLINE-2406: identity identification from saved settings only applies
|
||||
// to Cline-provider sessions; other providers use different auth flows.
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "openrouter",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs kanban before loading runtime modules", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "kanban"];
|
||||
|
||||
@@ -1156,30 +1013,12 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("skips hub prewarm for yolo runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
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(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1203,12 +1042,12 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects /team without quoted task text", async () => {
|
||||
it("shows /team usage in single-prompt mode when no task is provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
const stdoutWrite = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "/team"];
|
||||
@@ -1216,10 +1055,9 @@ 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(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown command or unquoted prompt: /team"),
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Usage: /team <task description>"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1228,14 +1066,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
@@ -1244,40 +1082,19 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves thinking unset when --thinking is not provided", async () => {
|
||||
it("leaves thinking disabled when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables thinking when --thinking none is explicitly provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
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(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
@@ -1291,14 +1108,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1321,14 +1138,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
@@ -1337,32 +1154,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5",
|
||||
reasoning: { enabled: false },
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
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(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning effort", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
@@ -1373,14 +1164,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
@@ -1394,13 +1185,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1416,19 +1207,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"basic",
|
||||
"say hello",
|
||||
];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1444,19 +1229,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"agentic",
|
||||
"say hello",
|
||||
];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1506,13 +1285,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: false,
|
||||
@@ -1551,7 +1330,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
authMocks.ensureOAuthProviderApiKey.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--json", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1559,7 +1338,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
outputMode: "json",
|
||||
apiKey: "",
|
||||
@@ -1578,7 +1357,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
authMocks.ensureOAuthProviderApiKey.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--json", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1586,7 +1365,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
outputMode: "json",
|
||||
apiKey: "",
|
||||
|
||||
+30
-80
@@ -15,12 +15,12 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext,
|
||||
} from "./utils/feature-flags";
|
||||
@@ -42,12 +42,10 @@ import {
|
||||
isOAuthProvider,
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { resolveCliReasoning } from "./utils/reasoning";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
getCliTelemetryService,
|
||||
identifyTelemetryAccount,
|
||||
} from "./utils/telemetry";
|
||||
import type { Config } from "./utils/types";
|
||||
import { runConnectWizard } from "./wizards/connect";
|
||||
@@ -114,23 +112,6 @@ export function resolveConfigDirArg(argv: string[]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -423,24 +404,15 @@ export async function runCli(): Promise<void> {
|
||||
"--transport <transport>",
|
||||
"stdio, sse, http, streamable-http, or streamableHttp (default: stdio)",
|
||||
)
|
||||
.option("--header <header>", "Remote MCP request header", collectOption, [])
|
||||
.option("--yes", "Install noninteractively without opening the wizard")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (name: string, targetArgs: string[]) => {
|
||||
const opts = mcpInstallCmd.opts<{
|
||||
header?: string[];
|
||||
json?: boolean;
|
||||
transport?: string;
|
||||
yes?: boolean;
|
||||
}>();
|
||||
const { runMcpInstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpInstallCommand({
|
||||
name,
|
||||
headers: opts.header,
|
||||
targetArgs,
|
||||
transport: opts.transport,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
yes: opts.yes === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
@@ -750,6 +722,13 @@ 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) {
|
||||
@@ -836,13 +815,6 @@ 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 =
|
||||
@@ -928,17 +900,6 @@ export async function runCli(): Promise<void> {
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// Register the SDK early logger as early as possible — before any
|
||||
// provider settings reads — so the full startup sequence is captured.
|
||||
// These components operate before/outside ClineCore sessions, so the
|
||||
// session-scoped logger can't reach them.
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "main",
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
@@ -968,26 +929,14 @@ export async function runCli(): Promise<void> {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
);
|
||||
let selectedProviderSettings =
|
||||
providerSettingsManager.getProviderSettings(provider);
|
||||
|
||||
// Apply locally persisted Cline account identity so subsequent events
|
||||
// (task.*, workspace.initialized) carry user_id when available.
|
||||
// Note: user.extension_activated fires anonymously earlier in startup
|
||||
// and cannot be retroactively updated; this is by design for
|
||||
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
|
||||
if (provider === "cline") {
|
||||
const savedAccountId = selectedProviderSettings?.auth?.accountId;
|
||||
if (savedAccountId) {
|
||||
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
|
||||
}
|
||||
}
|
||||
|
||||
const persistedApiKey = getPersistedProviderApiKey(
|
||||
provider,
|
||||
selectedProviderSettings,
|
||||
@@ -1049,13 +998,19 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const resolvedReasoning = resolveCliReasoning({
|
||||
thinking: args.thinking,
|
||||
thinkingExplicitlySet: args.thinkingExplicitlySet,
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
const persistedReasoning = selectedProviderSettings?.reasoning;
|
||||
const persistedReasoningEffort = persistedReasoning?.effort;
|
||||
const reasoningEffortFromSettings =
|
||||
persistedReasoning?.enabled === false
|
||||
? "none"
|
||||
: persistedReasoningEffort && persistedReasoningEffort !== "none"
|
||||
? persistedReasoningEffort
|
||||
: persistedReasoning?.enabled === true
|
||||
? "medium"
|
||||
: "none";
|
||||
const effectiveReasoningEffort = args.thinkingExplicitlySet
|
||||
? (args.reasoningEffort ?? "none")
|
||||
: (args.reasoningEffort ?? reasoningEffortFromSettings);
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -1091,8 +1046,11 @@ export async function runCli(): Promise<void> {
|
||||
sandbox: sandboxEnabled,
|
||||
sandboxDataDir,
|
||||
verbose: args.verbose,
|
||||
thinking: resolvedReasoning.thinking,
|
||||
reasoningEffort: resolvedReasoning.reasoningEffort,
|
||||
thinking: effectiveReasoningEffort !== "none",
|
||||
reasoningEffort:
|
||||
effectiveReasoningEffort === "none"
|
||||
? undefined
|
||||
: effectiveReasoningEffort,
|
||||
outputMode: args.outputMode,
|
||||
mode: args.mode,
|
||||
logger: loggerAdapter.core,
|
||||
@@ -1106,13 +1064,7 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: {
|
||||
name: "cline-cli",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
client: { name: "cline-cli" },
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
@@ -1213,9 +1165,7 @@ export async function runCli(): Promise<void> {
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
activeProviderId: provider,
|
||||
});
|
||||
initialNotice = getClineCliMigrationNotice();
|
||||
if (initialNotice) {
|
||||
markInitialNoticeShown = () => {
|
||||
markClineCliMigrationNoticeShown();
|
||||
|
||||
@@ -126,8 +126,7 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
@@ -158,8 +157,7 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
@@ -176,8 +174,7 @@ describe("compactInteractiveMessages", () => {
|
||||
messages,
|
||||
});
|
||||
|
||||
const compactedMessages = result.compactionState?.messages ?? [];
|
||||
const compactedTextLength = compactedMessages.reduce(
|
||||
const compactedTextLength = result.messages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(typeof message.content === "string" ? message.content.length : 0),
|
||||
@@ -185,9 +182,8 @@ describe("compactInteractiveMessages", () => {
|
||||
);
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(result.messages.length).toBeGreaterThan(1);
|
||||
expect(result.messages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
@@ -218,9 +214,8 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toHaveLength(messages.length);
|
||||
expect(result.compactionState?.messages[0]?.content).toBe(
|
||||
expect(result.messages).toHaveLength(messages.length);
|
||||
expect(result.messages[0]?.content).toBe(
|
||||
"same count but content should be trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
type ReasoningSettings,
|
||||
type SessionCompactionState,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
@@ -54,12 +52,7 @@ export async function compactInteractiveMessages(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
compacted: boolean;
|
||||
canonicalMessages: Message[];
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
}): Promise<{ compacted: boolean; messages: Message[] }> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
@@ -88,11 +81,8 @@ export async function compactInteractiveMessages(input: {
|
||||
{ mode: "manual" },
|
||||
);
|
||||
if (!compact) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
return { compacted: false, messages: input.messages };
|
||||
}
|
||||
// Manual compaction intentionally summarizes the full canonical transcript
|
||||
// instead of reusing a prior sidecar summary, which avoids summary-of-summary
|
||||
// drift across repeated `/compact` calls.
|
||||
const result = await compact({
|
||||
agentId: "cli",
|
||||
conversationId: input.sessionId,
|
||||
@@ -100,7 +90,7 @@ export async function compactInteractiveMessages(input: {
|
||||
iteration: 0,
|
||||
messages: input.messages,
|
||||
apiMessages: input.messages,
|
||||
abortSignal: input.abortSignal ?? new AbortController().signal,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
model: {
|
||||
@@ -113,17 +103,8 @@ export async function compactInteractiveMessages(input: {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result?.messages) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
}
|
||||
return {
|
||||
compacted: true,
|
||||
canonicalMessages: input.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
};
|
||||
return { compacted: true, messages: result.messages };
|
||||
}
|
||||
|
||||
@@ -2,15 +2,7 @@ import { createTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import {
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
type AppliedModeChange,
|
||||
applyInteractiveModeConfig,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./mode";
|
||||
import { applyInteractiveModeConfig } from "./mode";
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
|
||||
@@ -48,190 +40,6 @@ const switchToActModeTool = createTool({
|
||||
execute: async () => "ok",
|
||||
});
|
||||
|
||||
describe("createInteractiveModeSwitchTool", () => {
|
||||
function makeSwitchTool(config: Config) {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
} = { current: vi.fn() };
|
||||
const tool = createInteractiveModeSwitchTool({
|
||||
config,
|
||||
pendingModeChange,
|
||||
tuiModeChanged,
|
||||
});
|
||||
return { tool, pendingModeChange, tuiModeChanged };
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
agentId: "agent-1",
|
||||
iteration: 0,
|
||||
} as const;
|
||||
|
||||
it("completes the run so the model never continues with plan-mode tools", () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool } = makeSwitchTool(config);
|
||||
|
||||
// The act-mode tool set only exists after the session rebuild, which
|
||||
// happens between runs; without completesRun the model keeps working
|
||||
// with stale plan-mode tools after being told the switch succeeded.
|
||||
expect(tool.lifecycle?.completesRun).toBe(true);
|
||||
});
|
||||
|
||||
it("queues a tool-sourced mode change and notifies the TUI", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool, pendingModeChange, tuiModeChanged } = makeSwitchTool(config);
|
||||
|
||||
const result = await tool.execute({}, toolContext);
|
||||
|
||||
expect(pendingModeChange).toEqual({ current: "act", source: "tool" });
|
||||
expect(tuiModeChanged.current).toHaveBeenCalledWith("act");
|
||||
expect(result).toContain("successfully switched to act mode");
|
||||
});
|
||||
|
||||
it("errors instead of completing the run when already in act mode", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "act";
|
||||
const { tool, pendingModeChange } = makeSwitchTool(config);
|
||||
|
||||
// A successful result would end the run via completesRun even though
|
||||
// nothing changed, so the no-op case must surface as a tool error.
|
||||
await expect(tool.execute({}, toolContext)).rejects.toThrow(
|
||||
"Already in act mode.",
|
||||
);
|
||||
expect(pendingModeChange.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTurnWithActModeContinuation", () => {
|
||||
type TurnResult = { finishReason: string; iterations: number };
|
||||
|
||||
function makeHarness(input: {
|
||||
initial: TurnResult | undefined;
|
||||
continuation?: TurnResult | undefined;
|
||||
modeChanges: Array<AppliedModeChange | undefined>;
|
||||
}) {
|
||||
const applied = [...input.modeChanges];
|
||||
const sendContinuationTurn = vi.fn(async () => input.continuation);
|
||||
return {
|
||||
sendContinuationTurn,
|
||||
run: () =>
|
||||
sendTurnWithActModeContinuation<TurnResult>({
|
||||
sendInitialTurn: async () => input.initial,
|
||||
sendContinuationTurn,
|
||||
applyPendingModeChange: async () => applied.shift(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("continues the plan after a tool-initiated switch completes the run", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: { finishReason: "completed", iterations: 3 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).toHaveBeenCalledWith(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 5 });
|
||||
});
|
||||
|
||||
it("does not continue after a UI-initiated mode change", async () => {
|
||||
// A Tab toggle can race a natural turn completion; a "ui" source must
|
||||
// never start executing a plan the user did not approve.
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [{ mode: "act", source: "ui" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("does not continue when the switch turn was aborted", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "aborted", iterations: 1 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "aborted", iterations: 1 });
|
||||
});
|
||||
|
||||
it("does not continue when no mode change was pending", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("returns the switch turn result when the continuation yields nothing", async () => {
|
||||
const { run } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: undefined,
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModeSwitchNoticeTracker", () => {
|
||||
it("records a switch and clears it on consume", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels a round trip that returns to the mode the model last saw", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the original starting mode across chained switches", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
});
|
||||
|
||||
it("ignores a no-op switch", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("plan", "plan");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModeConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveSystemPrompt).mockClear();
|
||||
|
||||
@@ -2,42 +2,17 @@ import { createTool } from "@cline/shared";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
|
||||
export type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
/**
|
||||
* Pending mode change plus who requested it. The switch_to_act_mode tool and
|
||||
* the TUI mode toggle share this slot, but only a tool-initiated switch means
|
||||
* "the user approved the plan" -- a UI toggle that lands as a turn finishes
|
||||
* must not trigger plan execution.
|
||||
*/
|
||||
export type PendingModeChange = {
|
||||
current: InteractiveUiMode | null;
|
||||
source: "tool" | "ui" | null;
|
||||
};
|
||||
|
||||
export type AppliedModeChange = {
|
||||
mode: InteractiveUiMode;
|
||||
source: "tool" | "ui";
|
||||
};
|
||||
|
||||
/**
|
||||
* Canned prompt that drives the auto-continue turn after the model calls
|
||||
* switch_to_act_mode. It is a synthetic user message, so transcript hydration
|
||||
* filters it out of the chat display.
|
||||
*/
|
||||
export const ACT_MODE_CONTINUATION_PROMPT =
|
||||
"The user approved switching to act mode. Continue with the approved plan now.";
|
||||
type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
export function createInteractiveModeSwitchTool(input: {
|
||||
config: Config;
|
||||
pendingModeChange: PendingModeChange;
|
||||
pendingModeChange: { current: InteractiveUiMode | null };
|
||||
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
|
||||
}) {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
"Switch from plan mode to act mode. Call this after the user has confirmed they want to proceed with the plan. Do not call this proactively or before the user has agreed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -45,101 +20,17 @@ export function createInteractiveModeSwitchTool(input: {
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// The act-mode tools only exist after the session is rebuilt with the
|
||||
// new mode config, which can't happen mid-run. End the run right after
|
||||
// the tool result so the model never keeps working with plan-mode tools
|
||||
// it was just told it no longer has; run-interactive applies the pending
|
||||
// change and auto-continues on the rebuilt session.
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
if (input.config.mode === "act") {
|
||||
// Throw instead of returning: a successful result would end the
|
||||
// run via completesRun even though nothing changed.
|
||||
throw new Error("Already in act mode.");
|
||||
return "Already in act mode.";
|
||||
}
|
||||
input.pendingModeChange.current = "act";
|
||||
input.pendingModeChange.source = "tool";
|
||||
input.tuiModeChanged.current?.("act");
|
||||
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one interactive turn, and when the model ended it by calling
|
||||
* switch_to_act_mode, continues the approved plan on the rebuilt act-mode
|
||||
* session instead of waiting for the user to prompt again.
|
||||
*
|
||||
* The continuation only fires for a tool-initiated switch on a turn that
|
||||
* finished "completed": a UI toggle mid-run aborts the turn, and even if the
|
||||
* toggle races a natural completion its source is "ui", so the user's Tab
|
||||
* press can never start executing a plan they did not approve.
|
||||
*/
|
||||
export async function sendTurnWithActModeContinuation<
|
||||
T extends { finishReason: string; iterations: number },
|
||||
>(input: {
|
||||
sendInitialTurn: () => Promise<T | undefined>;
|
||||
sendContinuationTurn: (prompt: string) => Promise<T | undefined>;
|
||||
applyPendingModeChange: () => Promise<AppliedModeChange | undefined>;
|
||||
}): Promise<T | undefined> {
|
||||
const result = await input.sendInitialTurn();
|
||||
const switched = await input.applyPendingModeChange();
|
||||
if (
|
||||
switched?.mode !== "act" ||
|
||||
switched.source !== "tool" ||
|
||||
result?.finishReason !== "completed"
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
const continuation = await input.sendContinuationTurn(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
// Honor a mode toggle made while the continuation was running.
|
||||
await input.applyPendingModeChange();
|
||||
if (!continuation) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...continuation,
|
||||
iterations: result.iterations + continuation.iterations,
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
|
||||
@@ -1,89 +1,81 @@
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
|
||||
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
|
||||
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
markAbortInProgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
};
|
||||
}
|
||||
|
||||
function createChatCommandState(config = createConfig()): ChatCommandState {
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
@@ -92,35 +84,6 @@ function createChatCommandState(config = createConfig()): ChatCommandState {
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
return {
|
||||
getProviderSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ProviderSettingsManager;
|
||||
}
|
||||
|
||||
function createManifest(sessionId: string): SessionManifest {
|
||||
return {
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: 1,
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
cwd: "/tmp/project",
|
||||
workspace_root: "/tmp/project",
|
||||
enable_tools: true,
|
||||
enable_spawn: true,
|
||||
enable_teams: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function importRuntime() {
|
||||
return await import("./session-runtime");
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
@@ -137,9 +100,9 @@ function makeManager() {
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -151,13 +114,10 @@ function makeManager() {
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
updateSessionConnection: vi.fn(async () => {}),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -173,7 +133,7 @@ function makeTurnResult() {
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
@@ -190,22 +150,20 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function makeRuntime(
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
config?: Config;
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const config = options.config ?? createConfig();
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: createChatCommandState(config),
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
@@ -214,325 +172,26 @@ async function makeRuntime(
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
it("manual compact updates the active session sidecar without restarting", async () => {
|
||||
const sessionId = "sess-active";
|
||||
const messages = [
|
||||
{ id: "u1", role: "user" as const, content: "hello" },
|
||||
{ id: "a1", role: "assistant" as const, content: "world" },
|
||||
];
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
compactInteractiveMessagesMock.mockResolvedValue({
|
||||
compacted: true,
|
||||
canonicalMessages: messages,
|
||||
compactionState,
|
||||
});
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.compactCurrentSession();
|
||||
|
||||
expect(result).toEqual({
|
||||
messagesBefore: messages.length,
|
||||
messagesAfter: messages.length,
|
||||
workingContextMessagesAfter: compactionState.messages.length,
|
||||
compacted: true,
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(manager.stop).not.toHaveBeenCalled();
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
}),
|
||||
providerSettingsManager: expect.objectContaining({
|
||||
getProviderSettings: expect.any(Function),
|
||||
}),
|
||||
sessionId,
|
||||
messages,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
compactionState,
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("rejects manual compact while the active session is running", async () => {
|
||||
const sessionId = "sess-running";
|
||||
const messages = [{ role: "user" as const, content: "hello" }];
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
status: "running",
|
||||
}),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"Cannot compact while the current turn is running",
|
||||
);
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects manual compact when compaction is disabled", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
config.compaction = { enabled: false };
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"compaction is off",
|
||||
);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries compacted working context across mode-switch restarts", async () => {
|
||||
const firstSessionId = "sess-mode-before";
|
||||
const secondSessionId = "sess-mode-after";
|
||||
const prefixMessage = {
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: "large original",
|
||||
};
|
||||
const tailMessage = {
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: "new canonical tail",
|
||||
};
|
||||
const messages = [prefixMessage, tailMessage];
|
||||
const summaryMessage = {
|
||||
id: "summary",
|
||||
role: "user" as const,
|
||||
content: "summary",
|
||||
};
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: [prefixMessage],
|
||||
compactedMessages: [summaryMessage],
|
||||
conversationId: firstSessionId,
|
||||
systemPrompt: "compacted system",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: firstSessionId,
|
||||
manifest: createManifest(firstSessionId),
|
||||
manifestPath: "/tmp/session-before.json",
|
||||
messagesPath: "/tmp/session-before.messages.json",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: secondSessionId,
|
||||
manifest: createManifest(secondSessionId),
|
||||
manifestPath: "/tmp/session-after.json",
|
||||
messagesPath: "/tmp/session-after.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.applyMode("plan");
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
|
||||
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
|
||||
firstSessionId,
|
||||
);
|
||||
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
|
||||
const restartInput = manager.start.mock.calls[1]?.[0];
|
||||
expect(restartInput).toMatchObject({
|
||||
initialMessages: messages,
|
||||
initialCompactionState: expect.objectContaining({
|
||||
source_message_count: messages.length,
|
||||
messages: [summaryMessage, tailMessage],
|
||||
system_prompt: "compacted system",
|
||||
}),
|
||||
});
|
||||
expect(restartInput.initialCompactionState).not.toHaveProperty(
|
||||
"conversation_id",
|
||||
);
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
@@ -543,7 +202,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
@@ -551,54 +210,18 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
// Keep the replacement session's start in flight so the restart window
|
||||
// (old session stopped, no active session yet) stays open.
|
||||
const gate = deferred<void>();
|
||||
manager.start.mockImplementationOnce(async () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: createManifest("session-restarted"),
|
||||
manifestPath: "/tmp/session-restarted.json",
|
||||
messagesPath: "/tmp/session-restarted.messages.json",
|
||||
};
|
||||
});
|
||||
|
||||
const restart = runtime.restartWithCurrentMessages();
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A message submitted mid-restart (e.g. right after a plan/act toggle)
|
||||
// calls ensureReady; it must wait for the restart instead of booting a
|
||||
// blank session that races the replacement for the active slot.
|
||||
const ready = runtime.ensureReady();
|
||||
gate.resolve();
|
||||
await Promise.all([restart, ready]);
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
createRuntimeHooksMock.mockReturnValueOnce({
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = await makeRuntime(manager, {
|
||||
const runtime = makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
@@ -650,51 +273,14 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
resumeSessionId: "resumed-session",
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
@@ -702,14 +288,16 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ sessionId: "resumed-session" }),
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
@@ -726,46 +314,8 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
@@ -787,7 +337,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = await makeRuntime(manager);
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
@@ -815,125 +365,6 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
|
||||
const manager = makeManager();
|
||||
const config = {
|
||||
...createConfig(),
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
config.providerId = "openai-compatible";
|
||||
config.modelId = "custom-model";
|
||||
config.apiKey = "new-key";
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
}),
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the active session connection in place without restarting", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.updateCurrentSessionConnection({
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
|
||||
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
});
|
||||
|
||||
it("does not reuse the session id when restarting empty", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
const secondStart = manager.start.mock.calls[1]?.[0] as {
|
||||
config?: { sessionId?: string };
|
||||
};
|
||||
expect(secondStart?.config?.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
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 = await 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!: Awaited<ReturnType<typeof makeRuntime>>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "stale" }],
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = await 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[]>();
|
||||
@@ -943,7 +374,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = await makeRuntime(manager);
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
|
||||
@@ -2,13 +2,10 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -49,19 +46,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
|
||||
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
|
||||
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
|
||||
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
export type SessionConnectionUpdate = Parameters<
|
||||
CliCore["updateSessionConnection"]
|
||||
>[1];
|
||||
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];
|
||||
@@ -116,13 +103,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
let missingSessionRecoveryPromise:
|
||||
| Promise<MissingSessionRecovery>
|
||||
| undefined;
|
||||
let missingSessionRecoveryPromise: Promise<void> | 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;
|
||||
let manualCompactionAbortController: AbortController | undefined;
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
@@ -212,23 +196,15 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
// For continuing the same conversation, e.g. after a config change.
|
||||
sessionId?: string,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
...buildSessionConfig(),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
config: buildSessionConfig(),
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -299,53 +275,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
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" };
|
||||
const readCurrentMessages = async (): Promise<Message[]> => {
|
||||
if (!sessionManager || !activeSessionId) {
|
||||
return [];
|
||||
}
|
||||
return (await sessionManager.readMessages(activeSessionId)) ?? [];
|
||||
};
|
||||
|
||||
const readCompactionState = async (
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await manager.readSessionCompactionState(sessionId);
|
||||
} catch (error) {
|
||||
input.config.logger?.log?.("Failed to read session compaction state", {
|
||||
sessionId,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (
|
||||
error: unknown,
|
||||
): Promise<MissingSessionRecovery> => {
|
||||
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
|
||||
if (missingSessionRecoveryPromise) {
|
||||
return await missingSessionRecoveryPromise;
|
||||
}
|
||||
@@ -353,7 +290,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const manager = sessionManager;
|
||||
const missingSessionId = activeSessionId;
|
||||
if (!manager || !missingSessionId || shutdownRequested) {
|
||||
return { messages: [] };
|
||||
return;
|
||||
}
|
||||
const messages = await manager
|
||||
.readMessages(missingSessionId)
|
||||
@@ -370,22 +307,12 @@ export function createInteractiveSessionRuntime(input: {
|
||||
startupError = undefined;
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages);
|
||||
return { messages };
|
||||
})().finally(() => {
|
||||
missingSessionRecoveryPromise = undefined;
|
||||
});
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const readCurrentCompactionState = async (): Promise<
|
||||
SessionCompactionState | undefined
|
||||
> => {
|
||||
if (!activeSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return await readCompactionState(activeSessionId);
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -423,89 +350,19 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
reuseSessionId,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
}
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const [{ messages, status }, compactionState] = await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await restartWithMessages(
|
||||
messages,
|
||||
undefined,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
{ preserveSessionId: true },
|
||||
);
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
// No live session to update; the next startup builds its config from
|
||||
// the already-mutated CLI config, so nothing else is needed.
|
||||
return;
|
||||
}
|
||||
await manager.updateSessionConnection(sessionId, update);
|
||||
const messages = await readCurrentMessages();
|
||||
await restartWithMessages(messages);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
@@ -619,10 +476,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Cannot fork an empty session.");
|
||||
}
|
||||
const compactionState = await readCompactionState(forkedFromSessionId);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await manager.stop(forkedFromSessionId);
|
||||
const forkMetadata = buildForkSessionMetadata({
|
||||
forkedFromSessionId,
|
||||
@@ -630,17 +483,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
sourceSession: sessionRecord,
|
||||
messages,
|
||||
});
|
||||
await startFreshSession(
|
||||
messages,
|
||||
forkMetadata,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
};
|
||||
|
||||
@@ -662,52 +505,22 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
if (input.config.compaction?.enabled === false) {
|
||||
throw new Error(
|
||||
"Cannot compact because compaction is off for this session.",
|
||||
);
|
||||
}
|
||||
const manager = sessionManager;
|
||||
const sourceSessionId = activeSessionId;
|
||||
if (!manager || !sourceSessionId) {
|
||||
if (!sessionManager) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
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 messages = await readCurrentMessages();
|
||||
const messagesBefore = messages.length;
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const sessionRecord = await manager.get(sourceSessionId);
|
||||
if (sessionRecord?.status === "running") {
|
||||
throw new Error(
|
||||
"Cannot compact while the current turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof compactInteractiveMessages>>;
|
||||
const abortController = new AbortController();
|
||||
manualCompactionAbortController = abortController;
|
||||
try {
|
||||
result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: sourceSessionId,
|
||||
messages,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
} finally {
|
||||
if (manualCompactionAbortController === abortController) {
|
||||
manualCompactionAbortController = undefined;
|
||||
}
|
||||
}
|
||||
const result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: activeSessionId,
|
||||
messages,
|
||||
});
|
||||
if (!result.compacted) {
|
||||
return {
|
||||
messagesBefore,
|
||||
@@ -715,24 +528,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
if (!result.compactionState) {
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: messagesBefore,
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
const updated = await manager.updateSessionCompactionState(
|
||||
sourceSessionId,
|
||||
result.compactionState,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
throw new Error("Compaction could not be saved. Try again.");
|
||||
}
|
||||
await restartWithMessages(result.messages);
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: result.canonicalMessages.length,
|
||||
workingContextMessagesAfter: result.compactionState?.messages.length,
|
||||
messagesAfter: result.messages.length,
|
||||
compacted: true,
|
||||
};
|
||||
};
|
||||
@@ -752,10 +551,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return undefined;
|
||||
}
|
||||
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status !== "read") {
|
||||
return undefined;
|
||||
}
|
||||
const messages = await readCurrentMessages();
|
||||
return { messages, checkpointHistory };
|
||||
};
|
||||
|
||||
@@ -822,9 +618,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
abortRequested = true;
|
||||
markAbortInProgress();
|
||||
manualCompactionAbortController?.abort(
|
||||
new Error("Interactive runtime abort requested"),
|
||||
);
|
||||
sessionManager
|
||||
.abort(activeSessionId, new Error("Interactive runtime abort requested"))
|
||||
.catch(() => {});
|
||||
@@ -872,7 +665,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
compactCurrentSession,
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
@@ -24,7 +20,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
When the user aligns on a plan and is ready to proceed, use the switch_to_act_mode tool to switch to act mode and begin implementation.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
@@ -35,13 +31,10 @@ export async function resolveSystemPrompt(input: {
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
rules = rules
|
||||
? `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`
|
||||
: PLAN_MODE_INSTRUCTIONS;
|
||||
}
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
|
||||
@@ -27,37 +27,11 @@ const outputMocks = vi.hoisted(() => ({
|
||||
c: { dim: "", reset: "" },
|
||||
}));
|
||||
|
||||
const sessionEventsMocks = vi.hoisted(() => ({
|
||||
listener: undefined as ((event: unknown) => void) | undefined,
|
||||
subscribeToAgentEvents: vi.fn(
|
||||
(_: unknown, listener: (event: unknown) => void) => {
|
||||
sessionEventsMocks.listener = listener;
|
||||
return () => {};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const CLI_SUBSCRIPTION_URL =
|
||||
"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_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
"ClinePass limit reached",
|
||||
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
].join("\n");
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
"https://app.cline.bot/dashboard/subscription/";
|
||||
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
@@ -65,39 +39,6 @@ vi.mock("@cline/core", () => ({
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
extractClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
const prefix = "you have reached your";
|
||||
const suffix = "please try again later.";
|
||||
const start = normalized.indexOf(prefix);
|
||||
if (start === -1) return undefined;
|
||||
const suffixStart = normalized.indexOf(suffix, start);
|
||||
if (suffixStart === -1) return undefined;
|
||||
const end = suffixStart + suffix.length;
|
||||
if (!normalized.slice(start, end).includes("clinepass limit")) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start, end);
|
||||
},
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -147,7 +88,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -171,9 +112,6 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -585,7 +523,7 @@ describe("runAgent", () => {
|
||||
});
|
||||
|
||||
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
|
||||
const error = new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE);
|
||||
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
|
||||
error.name = "ClineNotSubscribedError";
|
||||
sessionManagerMocks.start.mockRejectedValue(error);
|
||||
|
||||
@@ -613,7 +551,7 @@ describe("runAgent", () => {
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -691,7 +629,7 @@ describe("runAgent", () => {
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -735,193 +673,10 @@ describe("runAgent", () => {
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass subscription errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_LIMIT_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -1183,121 +938,4 @@ describe("runAgent", () => {
|
||||
expect.stringContaining("est. cost"),
|
||||
);
|
||||
});
|
||||
|
||||
it("zeros Cline free model costs in JSON results and agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: {
|
||||
session_id: "session-1",
|
||||
},
|
||||
result: {
|
||||
text: "completed text",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
model: {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
provider: "cline",
|
||||
info: {},
|
||||
},
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
aggregateUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
const { handleEvent } = await import("../utils/events");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: {
|
||||
maxConsecutiveMistakes: 3,
|
||||
},
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
outputMode: "json",
|
||||
providerId: "cline",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const runResult = outputMocks.emitJsonLine.mock.calls.find(
|
||||
([, payload]) =>
|
||||
(payload as { type?: string } | undefined)?.type === "run_result",
|
||||
)?.[1] as
|
||||
| {
|
||||
usage?: { totalCost?: number };
|
||||
aggregateUsage?: { totalCost?: number };
|
||||
}
|
||||
| undefined;
|
||||
expect(runResult?.usage?.totalCost).toBe(0);
|
||||
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
|
||||
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "usage",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cost: 0.25,
|
||||
totalCost: 0.25,
|
||||
});
|
||||
|
||||
expect(handleEvent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,6 @@ import {
|
||||
} from "../utils/approval";
|
||||
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
|
||||
import { handleEvent, handleTeamEvent } from "../utils/events";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import { createRuntimeHooks } from "../utils/hooks";
|
||||
import {
|
||||
c,
|
||||
@@ -189,10 +184,8 @@ export async function runAgent(
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
const displayedErrorMessages = new Set<string>();
|
||||
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
|
||||
|
||||
const onAgentEvent = (rawEvent: AgentEvent): void => {
|
||||
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
if (event.type === "content_start" && event.contentType === "reasoning") {
|
||||
reasoningChunkCount += 1;
|
||||
if (event.redacted) {
|
||||
@@ -204,9 +197,7 @@ export async function runAgent(
|
||||
(!event.recoverable || config.verbose) &&
|
||||
event.error.message.trim()
|
||||
) {
|
||||
displayedErrorMessages.add(
|
||||
formatCliErrorMessage(event.error.message).trim(),
|
||||
);
|
||||
displayedErrorMessages.add(event.error.message.trim());
|
||||
}
|
||||
handleEvent(event, config);
|
||||
};
|
||||
@@ -348,14 +339,8 @@ export async function runAgent(
|
||||
const usageSummary = await sessionManager.getAccumulatedUsage(
|
||||
started.sessionId,
|
||||
);
|
||||
const aggregateUsage = zeroCliUsageCost(
|
||||
usageSummary?.aggregateUsage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const usage = zeroCliUsageCost(
|
||||
aggregateUsage ?? usageSummary?.usage ?? result.usage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const aggregateUsage = usageSummary?.aggregateUsage;
|
||||
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
||||
|
||||
if (config.outputMode === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
resolveReasoningForModelChange,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: false, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "high" } },
|
||||
),
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning with the selected effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: "low" },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "low" });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("preserves existing reasoning when thinking is unset", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: undefined, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "medium" } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModelChange", () => {
|
||||
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
} as Config;
|
||||
const getProviderSettings = vi.fn(() => ({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible" as const,
|
||||
protocol: "openai-chat" as const,
|
||||
model: "old-model",
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
const updateCurrentSessionConnection = vi.fn(async () => {});
|
||||
|
||||
await applyInteractiveModelChange({
|
||||
config,
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
},
|
||||
sessionRuntime: {
|
||||
ensureReady,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible",
|
||||
protocol: "openai-chat",
|
||||
model: "custom-model",
|
||||
});
|
||||
expect(ensureReady).toHaveBeenCalledOnce();
|
||||
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
|
||||
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
});
|
||||
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
ProviderSettingsManager,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
loadIndividualSubscriptionPlans,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
@@ -26,11 +24,6 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
@@ -53,80 +46,12 @@ import {
|
||||
type InteractiveExitSummary,
|
||||
} from "./interactive/exit-summary";
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./interactive/mode";
|
||||
import { createInteractiveModeSwitchTool } from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { getUIEventEmitter } from "./session-events";
|
||||
|
||||
type ModelChangeReasoningConfig = {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: Config["reasoningEffort"];
|
||||
};
|
||||
|
||||
export function resolveReasoningForModelChange(
|
||||
config: ModelChangeReasoningConfig,
|
||||
existing: Pick<ProviderSettings, "reasoning">,
|
||||
): ProviderSettings["reasoning"] {
|
||||
if (config.thinking === false) return { enabled: false };
|
||||
if (config.reasoningEffort) {
|
||||
return { enabled: true, effort: config.reasoningEffort };
|
||||
}
|
||||
if (config.thinking === true) return { enabled: true };
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function applyInteractiveModelChange(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<
|
||||
ProviderSettingsManager,
|
||||
"getProviderSettings" | "saveProviderSettings"
|
||||
>;
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
| "ensureReady"
|
||||
| "restartWithCurrentMessages"
|
||||
| "updateCurrentSessionConnection"
|
||||
>;
|
||||
}): Promise<void> {
|
||||
const { config, providerSettingsManager, sessionRuntime } = input;
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
|
||||
// Provider changes affect more than the model connection: startup resolves
|
||||
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
|
||||
// the runtime with the existing transcript so all of that state changes
|
||||
// together. restartWithCurrentMessages preserves the session ID.
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
// A same-ID restart reuses the existing manifest. Sync its connection label
|
||||
// after the fully configured runtime is live so session history reflects the
|
||||
// provider/model that will handle subsequent turns.
|
||||
await sessionRuntime.updateCurrentSessionConnection({
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -201,9 +126,8 @@ export async function runInteractive(
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
const pendingModeChange: { current: "plan" | "act" | null } = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
@@ -229,7 +153,6 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
@@ -243,7 +166,7 @@ export async function runInteractive(
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
uiEvents.emit("agent", event);
|
||||
},
|
||||
onTeamEvent: (event) => {
|
||||
uiEvents.emit("team", event);
|
||||
@@ -257,7 +180,6 @@ export async function runInteractive(
|
||||
});
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
|
||||
const isInteractiveMode = (mode: unknown): mode is "plan" | "act" =>
|
||||
mode === "plan" || mode === "act";
|
||||
@@ -272,11 +194,7 @@ export async function runInteractive(
|
||||
await modeChangePromise;
|
||||
}
|
||||
await sessionRuntime.ensureReady();
|
||||
const from = config.mode;
|
||||
await sessionRuntime.applyMode(mode);
|
||||
if (isInteractiveMode(from)) {
|
||||
modeSwitchNotice.record(from, mode);
|
||||
}
|
||||
})().finally(() => {
|
||||
if (modeChangePromise === next) {
|
||||
modeChangePromise = undefined;
|
||||
@@ -447,7 +365,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,
|
||||
@@ -486,12 +404,6 @@ export async function runInteractive(
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
}),
|
||||
loadIndividualSubscriptionPlans: async () =>
|
||||
await loadIndividualSubscriptionPlans({
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
clineProviderSettings: options?.clineProviderSettings,
|
||||
}),
|
||||
switchClineAccount: async (organizationId) =>
|
||||
await switchClineAccount({
|
||||
config,
|
||||
@@ -520,7 +432,6 @@ export async function runInteractive(
|
||||
},
|
||||
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
|
||||
let commandOutput: string | undefined;
|
||||
let zeroTurnCost = false;
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
await waitForSubmittedMode(mode);
|
||||
@@ -567,8 +478,6 @@ export async function runInteractive(
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
commandOutput = chatCommandResult.commandOutput;
|
||||
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
|
||||
zeroCurrentTurnCost = zeroTurnCost;
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
@@ -578,50 +487,27 @@ export async function runInteractive(
|
||||
...(attachments?.userImages ?? []),
|
||||
...userImages,
|
||||
];
|
||||
// Mark a preceding user-initiated mode switch on this message so
|
||||
// the model sees exactly when the rules changed, instead of only
|
||||
// inferring it from the user_input mode attribute flipping.
|
||||
const switchNotice = modeSwitchNotice.consume();
|
||||
const noticedUserInput = switchNotice
|
||||
? `${formatModeSwitchNotice(switchNotice.from, switchNotice.to)}\n${userInput}`
|
||||
: userInput;
|
||||
|
||||
const applyPendingModeChange = async (): Promise<
|
||||
AppliedModeChange | undefined
|
||||
> => {
|
||||
const applyPendingModeChange = async () => {
|
||||
if (!pendingModeChange.current) return undefined;
|
||||
const applied: AppliedModeChange = {
|
||||
mode: pendingModeChange.current,
|
||||
source: pendingModeChange.source ?? "ui",
|
||||
};
|
||||
const newMode = pendingModeChange.current;
|
||||
pendingModeChange.current = null;
|
||||
pendingModeChange.source = null;
|
||||
const from = config.mode;
|
||||
await sessionRuntime.applyMode(applied.mode);
|
||||
tuiModeChanged.current?.(applied.mode);
|
||||
// The switch_to_act_mode path announces itself through the
|
||||
// continuation prompt; only UI toggles need a notice.
|
||||
if (applied.source === "ui" && isInteractiveMode(from)) {
|
||||
modeSwitchNotice.record(from, applied.mode);
|
||||
}
|
||||
return applied;
|
||||
await sessionRuntime.applyMode(newMode);
|
||||
tuiModeChanged.current?.(newMode);
|
||||
return newMode;
|
||||
};
|
||||
|
||||
const result = await sendTurnWithActModeContinuation({
|
||||
sendInitialTurn: () =>
|
||||
sessionRuntime.sendCurrentTurn({
|
||||
prompt: noticedUserInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
}),
|
||||
sendContinuationTurn: (prompt) =>
|
||||
sessionRuntime.sendCurrentTurn({ prompt, mode: "act" }),
|
||||
applyPendingModeChange,
|
||||
const result = await sessionRuntime.sendCurrentTurn({
|
||||
prompt: userInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
});
|
||||
|
||||
await applyPendingModeChange();
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
@@ -633,9 +519,8 @@ export async function runInteractive(
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
if (result.finishReason === "aborted" || isAbortInProgress()) {
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(
|
||||
result.usage,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
@@ -650,10 +535,7 @@ export async function runInteractive(
|
||||
errorText || `Turn finished with ${result.finishReason}`,
|
||||
);
|
||||
}
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
@@ -677,7 +559,6 @@ export async function runInteractive(
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
zeroCurrentTurnCost = false;
|
||||
if (!delivery) {
|
||||
isRunning = false;
|
||||
clearAbortInProgress();
|
||||
@@ -723,7 +604,6 @@ export async function runInteractive(
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
sessionRuntime.abortAll();
|
||||
return;
|
||||
}
|
||||
@@ -732,12 +612,26 @@ export async function runInteractive(
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: () =>
|
||||
applyInteractiveModelChange({
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerSettingsManager,
|
||||
sessionRuntime,
|
||||
}),
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
reasoning: config.reasoningEffort
|
||||
? { enabled: true, effort: config.reasoningEffort }
|
||||
: { enabled: false },
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onSessionRestart: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.restartEmpty();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
normalizeUserInput,
|
||||
type ToolResultContent,
|
||||
type ToolUseContent,
|
||||
} from "@cline/shared";
|
||||
import { formatStructuredCommand } from "../utils/helpers";
|
||||
|
||||
export interface ConversationHistory {
|
||||
version: number;
|
||||
@@ -681,7 +680,7 @@ function renderContentHTML(
|
||||
toolResultsMap: Map<string, ToolResultContent>,
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
const text = isUser ? formatDisplayUserInput(content) : content;
|
||||
const text = isUser ? normalizeUserInput(content) : content;
|
||||
return renderTextHTML(text);
|
||||
}
|
||||
|
||||
@@ -689,7 +688,7 @@ function renderContentHTML(
|
||||
.map((block) => {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
const text = isUser ? formatDisplayUserInput(block.text) : block.text;
|
||||
const text = isUser ? normalizeUserInput(block.text) : block.text;
|
||||
return renderTextHTML(text);
|
||||
}
|
||||
case "tool_use":
|
||||
@@ -846,15 +845,15 @@ function renderDiffHTML(
|
||||
}
|
||||
|
||||
function renderCommandsHTML(
|
||||
commands: unknown[],
|
||||
commands: string[],
|
||||
_result?: ToolResultContent,
|
||||
): string {
|
||||
return commands
|
||||
.map(
|
||||
(command, i) => `
|
||||
(cmd, i) => `
|
||||
<div class="command-block">
|
||||
<div class="command-label">Command ${i + 1}</div>
|
||||
<code>${escapeHtml(formatStructuredCommand(command))}</code>
|
||||
<code>${escapeHtml(cmd)}</code>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -3,11 +3,7 @@ import { CLINE_BIN } from "./helpers/constants.js";
|
||||
import { clineEnv } from "./helpers/env.js";
|
||||
import { expectVisible } from "./helpers/terminal.js";
|
||||
|
||||
// Wide enough that long option descriptions (e.g. --thinking) render on a
|
||||
// single line. At narrower widths commander wraps them, splitting phrases
|
||||
// like "omitted leaves provider default" across lines so the contiguous
|
||||
// getByText assertions below fail.
|
||||
const HELP_TERMINAL = { columns: 200, rows: 50 };
|
||||
const HELP_TERMINAL = { columns: 120, rows: 50 };
|
||||
|
||||
// ===========================================================================
|
||||
// Root-level flag descriptions
|
||||
@@ -27,9 +23,7 @@ test.describe("root flag descriptions", () => {
|
||||
"verbose output",
|
||||
"Working directory",
|
||||
"Configuration directory",
|
||||
"Set reasoning effort:",
|
||||
"Bare --thinking uses medium",
|
||||
"omitted leaves provider default",
|
||||
"Set reasoning effort level",
|
||||
"consecutive mistakes",
|
||||
"Output messages as JSON",
|
||||
"Check for updates and install if available",
|
||||
|
||||
@@ -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_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
NO_UPDATE_NOTIFIER: "1",
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
...extra,
|
||||
|
||||
@@ -12,8 +12,6 @@ const coreMocks = vi.hoisted(() => {
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
fetchCurrentUserPlan: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
@@ -41,14 +39,6 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
fetchAvailableSubscriptionPlans(input?: {
|
||||
type?: "individual" | "teams";
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
fetchCurrentUserPlan() {
|
||||
return coreMocks.fetchCurrentUserPlan();
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -110,8 +100,6 @@ describe("createClineAccountService", () => {
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -208,8 +196,6 @@ describe("loadClineAccountSnapshot", () => {
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -263,49 +249,3 @@ describe("loadClineAccountSnapshot", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadIndividualSubscriptionPlans", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads individual subscription plans through the authorized account service", async () => {
|
||||
const plans = [
|
||||
{
|
||||
id: "plan-1",
|
||||
interval: "Monthly",
|
||||
features: { included: ["Major open-weights models"] },
|
||||
},
|
||||
];
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
|
||||
|
||||
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
|
||||
const result = await loadIndividualSubscriptionPlans({
|
||||
config: makeConfig(),
|
||||
});
|
||||
|
||||
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
|
||||
type: "individual",
|
||||
});
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
@@ -126,10 +124,8 @@ export async function createClineAccountService(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager =
|
||||
input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const manager = new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
@@ -207,60 +203,6 @@ export async function switchClineAccount(input: {
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlans(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
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({
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { ClineSubscriptionPlan } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -19,13 +13,12 @@ import {
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getUserMessageBackground,
|
||||
getModeInputBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { getSyntaxStyle } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
parseApplyPatchInput,
|
||||
@@ -271,8 +264,7 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -287,105 +279,48 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
|
||||
}
|
||||
content="You have run out of Cline credits. Add credits in the dashboard to continue."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="gray">Dashboard: </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={palette.act} 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) {
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
void props
|
||||
.loadIndividualSubscriptionPlans()
|
||||
.then((plans) => {
|
||||
if (isMounted) {
|
||||
setPlanFeatures(getIndividualPlanFeatures(plans));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the subscription error view usable if plan metadata is unavailable.
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [props.loadIndividualSubscriptionPlans]);
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getClinePassSubscriptionUrl();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={planAccent} content="* " />
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={planAccent}
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg={planAccent}>ClinePass subscription required</text>
|
||||
<text fg="yellow">ClinePass subscription required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
|
||||
/>
|
||||
{planFeatures.length > 0 && (
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={props.defaultFg}>ClinePass includes:</text>
|
||||
{planFeatures.map((feature) => (
|
||||
<text key={feature} fg={props.defaultFg} selectable>
|
||||
<span fg="green">✓ </span>
|
||||
<span>{feature}</span>
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -394,93 +329,18 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={planAccent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={planAccent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg={planAccent}>Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={getClineOrgIndividualInferenceSubscriptionMessage()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Switch to Cline usage-based billing and retry with the Cline provider."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Interactive CLI: </text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="type /model, press tab to change provider, choose Cline, then retry."
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Headless CLI: </text>
|
||||
<text fg={props.defaultFg} selectable content="rerun with " />
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
/** Mode the entry was produced in (resolved with the current-mode fallback). */
|
||||
mode?: SyntaxAccentMode;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
|
||||
const { entry, accent = palette.act, terminalTheme } = props;
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const userMsgBg = getUserMessageBackground(terminalBg);
|
||||
const userMsgBg = getModeInputBackground(
|
||||
accent === palette.plan ? "plan" : "act",
|
||||
terminalBg,
|
||||
);
|
||||
|
||||
switch (entry.kind) {
|
||||
case "user":
|
||||
@@ -491,9 +351,10 @@ export function ChatEntryView(props: {
|
||||
marginX={-1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<box width={2}>
|
||||
<text fg={accent}>{"❯"}</text>
|
||||
<text fg={accent}>{">"}</text>
|
||||
</box>
|
||||
<text fg={defaultFg} selectable>
|
||||
{entry.text}
|
||||
@@ -509,9 +370,10 @@ export function ChatEntryView(props: {
|
||||
marginX={-1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<box width={2}>
|
||||
<text fg={accent}>{"❯"}</text>
|
||||
<text fg={accent}>{">"}</text>
|
||||
</box>
|
||||
{entry.delivery === "steer" && <text fg="yellow">[steer] </text>}
|
||||
{entry.delivery === "queue" && <text fg="gray">[queued] </text>}
|
||||
@@ -536,7 +398,7 @@ export function ChatEntryView(props: {
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme)}
|
||||
streaming={entry.streaming}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
@@ -565,33 +427,8 @@ export function ChatEntryView(props: {
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassSubscriptionError(entry.text)) {
|
||||
return (
|
||||
<ClinePassSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
@@ -621,7 +458,7 @@ export function ChatEntryView(props: {
|
||||
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
|
||||
if (entry.tokens > 0)
|
||||
parts.push(`${entry.tokens.toLocaleString()} tokens`);
|
||||
if (entry.cost > 0) parts.push(`$${entry.cost.toFixed(2)}`);
|
||||
if (entry.cost > 0) parts.push(`$${entry.cost.toFixed(3)}`);
|
||||
if (entry.iterations > 0)
|
||||
parts.push(
|
||||
`${entry.iterations} iteration${entry.iterations !== 1 ? "s" : ""}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "opentui-spinner/react";
|
||||
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -21,7 +21,6 @@ export interface TranscriptScrollHandle {
|
||||
interface ChatMessageListProps {
|
||||
entries: ChatEntry[];
|
||||
isStreaming?: boolean;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
uiMode?: AgentMode;
|
||||
}
|
||||
|
||||
@@ -96,18 +95,11 @@ export const ChatMessageList = forwardRef<
|
||||
<box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
|
||||
{props.entries.map((entry, i) => {
|
||||
const key = `${i}:${entry.kind}`;
|
||||
// Single source of truth for the entry's mode: the glyph accent
|
||||
// and the markdown accent must never diverge.
|
||||
const entryMode = entry.mode ?? props.uiMode ?? "act";
|
||||
return (
|
||||
<ChatEntryView
|
||||
key={key}
|
||||
entry={entry}
|
||||
accent={getModeAccent(entryMode, terminalTheme)}
|
||||
mode={entryMode === "plan" ? "plan" : "act"}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
accent={accent}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -424,7 +424,7 @@ export function AccountDialogContent(
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">Cline Account</text>
|
||||
<text fg="gray">{state.message}</text>
|
||||
<text fg="gray">Esc to close</text>
|
||||
</box>
|
||||
@@ -434,7 +434,7 @@ export function AccountDialogContent(
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">Cline Account</text>
|
||||
<text fg="red">{state.message}</text>
|
||||
<text fg="gray">Esc to close</text>
|
||||
</box>
|
||||
@@ -444,7 +444,7 @@ export function AccountDialogContent(
|
||||
if (state.status === "unauthenticated") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">Cline Account</text>
|
||||
<text>Sign in or create a Cline account.</text>
|
||||
<text fg="gray">
|
||||
Get access to the latest models with regular free promos and
|
||||
@@ -473,7 +473,7 @@ export function AccountDialogContent(
|
||||
if (view === "organizations") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg={palette.act}>Change Account</text>
|
||||
<text fg="cyan">Change Account</text>
|
||||
<box flexDirection="column" gap={0}>
|
||||
{orgRows.map((row, index) => (
|
||||
<OrganizationRow
|
||||
@@ -503,7 +503,7 @@ export function AccountDialogContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">Cline Account</text>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
@@ -514,7 +514,7 @@ export function AccountDialogContent(
|
||||
border
|
||||
borderColor="gray"
|
||||
>
|
||||
<text fg={palette.act}>{userInitial(loaded)}</text>
|
||||
<text fg="cyan">{userInitial(loaded)}</text>
|
||||
</box>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<text selectable>{displayName}</text>
|
||||
|
||||
@@ -191,7 +191,7 @@ export function CommandPaletteContent(
|
||||
{" "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : palette.act}
|
||||
fg={isSelected ? palette.textOnSelection : "cyan"}
|
||||
width={shortcutWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ExtDetailContent(
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{row.name}</strong>
|
||||
</text>
|
||||
<text
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
type HelpRow =
|
||||
| { kind: "heading"; id: string; text: string }
|
||||
@@ -278,7 +277,7 @@ export function HelpDialogContent(props: ChoiceContext<void>) {
|
||||
}
|
||||
return (
|
||||
<box key={row.id} flexDirection="row" paddingX={1}>
|
||||
<text fg={palette.act} width={KEY_WIDTH} flexShrink={0}>
|
||||
<text fg="cyan" width={KEY_WIDTH} flexShrink={0}>
|
||||
{row.key}
|
||||
</text>
|
||||
<text fg="gray">{row.desc}</text>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function McpManagerContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg={palette.act}>MCP Servers</text>
|
||||
<text fg="cyan">MCP Servers</text>
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
Settings file:
|
||||
@@ -141,7 +141,7 @@ export function McpManagerContent(
|
||||
const enabledIcon =
|
||||
typeof srv.enabled === "boolean" ? (enabled ? "● " : "○ ") : "";
|
||||
const status = getMcpManagerEntryStatus(srv);
|
||||
let rowColor = isSel ? palette.act : "gray";
|
||||
let rowColor = isSel ? "cyan" : "gray";
|
||||
if (enabled && typeof srv.enabled === "boolean") {
|
||||
rowColor = palette.success;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
getProviderAuthStorageId,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Persist a manually entered API key for an OAuth-capable provider — the
|
||||
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
|
||||
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
|
||||
* stale token would otherwise keep winning over the manual key.
|
||||
*
|
||||
* The key is written both to the provider's auth storage entry (cline-pass
|
||||
* stores credentials under "cline") and to the provider's own entry: settings
|
||||
* resolution lets a direct entry shadow the storage entry, and provider
|
||||
* switching copies merged settings (including auth) into direct entries, so
|
||||
* both must be updated for the manual key to reliably take effect.
|
||||
*/
|
||||
export function saveManualProviderApiKey(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): void {
|
||||
// Empty strings delete these keys from the stored auth object.
|
||||
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId: storageProviderId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
if (
|
||||
providerId !== storageProviderId &&
|
||||
manager.read().providers[providerId]
|
||||
) {
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isProviderConfigured,
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveManualProviderApiKey", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createManager(): ProviderSettingsManager {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
|
||||
tempDirs.push(dir);
|
||||
return new ProviderSettingsManager({
|
||||
filePath: join(dir, "providers.json"),
|
||||
});
|
||||
}
|
||||
|
||||
it("clears stored OAuth tokens so the manual key takes effect", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
accountId: "acct_123",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline", "manual-api-key");
|
||||
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
expect(settings?.apiKey).toBe("manual-api-key");
|
||||
expect(settings?.auth?.accessToken).toBeUndefined();
|
||||
expect(settings?.auth?.refreshToken).toBeUndefined();
|
||||
expect(settings?.auth?.accountId).toBe("acct_123");
|
||||
expect(getPersistedProviderApiKey("cline", settings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline", settings)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves cline-pass keys to the shared cline auth storage entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
// cline-pass inherits auth storage from the "cline" entry, so the key
|
||||
// must land there and the stale tokens must be gone for both providers.
|
||||
const clineSettings = manager.getProviderSettings("cline");
|
||||
expect(clineSettings?.apiKey).toBe("manual-api-key");
|
||||
expect(clineSettings?.auth?.accessToken).toBeUndefined();
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears stale credentials copied into a direct cline-pass entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
// Provider switching copies the merged settings (including auth) into
|
||||
// a direct cline-pass entry, which shadows the shared "cline" entry.
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline-pass",
|
||||
apiKey: "stale-copied-key",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,10 +37,6 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -252,33 +248,18 @@ export function ProviderPickerContent(
|
||||
);
|
||||
}
|
||||
|
||||
export type ExistingProviderAction =
|
||||
| "use_existing"
|
||||
| "reconfigure"
|
||||
| "open_subscription_page"
|
||||
| "open_usage_billing";
|
||||
|
||||
export interface ExistingProviderOption {
|
||||
value: ExistingProviderAction;
|
||||
label: string;
|
||||
onSelect?: () => Promise<void> | void;
|
||||
}
|
||||
export type ExistingProviderAction = "use_existing" | "reconfigure";
|
||||
|
||||
export function UseExistingOrReconfigureContent(
|
||||
props: ChoiceContext<ExistingProviderOption> & {
|
||||
props: ChoiceContext<ExistingProviderAction> & {
|
||||
providerName: string;
|
||||
extraOptions?: ExistingProviderOption[];
|
||||
},
|
||||
) {
|
||||
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 { resolve, dismiss, dialogId, providerName } = props;
|
||||
const options: { value: ExistingProviderAction; label: string }[] = [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
{ value: "reconfigure", label: "Configure again" },
|
||||
];
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
@@ -288,7 +269,7 @@ export function UseExistingOrReconfigureContent(
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const opt = options[selected];
|
||||
if (opt) resolve(opt);
|
||||
if (opt) resolve(opt.value);
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
@@ -333,86 +314,6 @@ 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={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text>{status}</text>
|
||||
|
||||
<text fg="gray">{pageLabel}:</text>
|
||||
<text fg={palette.act} 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",
|
||||
@@ -599,7 +500,7 @@ export function ProviderConfigInputContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -692,7 +593,7 @@ export function CodexCliStatusContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -710,7 +611,7 @@ export function CodexCliStatusContent(
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="gray">{status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
@@ -727,27 +628,13 @@ export function CodexCliStatusContent(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `true` on successful login, `"use_api_key"` when the user opts
|
||||
* into manual API key entry (only offered with `allowApiKeyFallback`).
|
||||
*/
|
||||
export type OAuthLoginResult = boolean | "use_api_key";
|
||||
|
||||
export function OAuthLoginContent(
|
||||
props: ChoiceContext<OAuthLoginResult> & {
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
allowApiKeyFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const { resolve, dismiss, dialogId, providerId, providerName } = props;
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -880,22 +767,13 @@ export function OAuthLoginContent(
|
||||
if (key.name === "escape") {
|
||||
cancelAuthAttempt();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "k" && allowApiKeyFallback) {
|
||||
cancelAuthAttempt();
|
||||
resolve("use_api_key");
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -910,7 +788,7 @@ export function OAuthLoginContent(
|
||||
<strong>{deviceUserCode}</strong>
|
||||
</text>
|
||||
<text fg="gray">Visit this URL and enter the code above:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={deviceVerifyUrl}>{deviceVerifyUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -919,7 +797,7 @@ export function OAuthLoginContent(
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>{escapeHint}</em>
|
||||
<em>Esc to cancel</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -927,7 +805,7 @@ export function OAuthLoginContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -942,82 +820,7 @@ export function OAuthLoginContent(
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual API key entry for OAuth-capable providers — the escape hatch for
|
||||
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
|
||||
* the manual key takes effect (see saveManualProviderApiKey).
|
||||
*/
|
||||
export function OAuthApiKeyInputContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const apiKey = value.trim();
|
||||
if (!apiKey) return;
|
||||
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
submit();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
Use an API key from your Cline dashboard instead of OAuth login. This
|
||||
replaces any saved login tokens.
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">API key</text>
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<input
|
||||
value={value}
|
||||
onInput={setValue}
|
||||
placeholder="Paste your API key"
|
||||
flexGrow={1}
|
||||
focused
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Enter to save, Esc to go back</em>
|
||||
<em>Esc to cancel</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -142,7 +142,7 @@ export function SkillsPickerContent(props: SkillsPickerContentProps) {
|
||||
onMouseDown={() => resolve(SKILLS_MARKETPLACE_ACTION)}
|
||||
height={1}
|
||||
>
|
||||
<text fg={isSelected ? palette.textOnSelection : palette.act}>
|
||||
<text fg={isSelected ? palette.textOnSelection : "cyan"}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
Browse more skills at {SKILLS_MARKETPLACE_URL}
|
||||
</text>
|
||||
|
||||
@@ -155,7 +155,7 @@ export function ToolApprovalContent(
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg="yellow">Approve tool call?</text>
|
||||
|
||||
<text fg={palette.act} marginTop={1}>
|
||||
<text fg="cyan" marginTop={1}>
|
||||
<strong>{props.request.toolName}</strong>
|
||||
</text>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export type TextareaHandle = Pick<
|
||||
|
||||
export interface InputBarProps {
|
||||
accent: string;
|
||||
ruleColor: string;
|
||||
inputBackground: string;
|
||||
inputForeground: string;
|
||||
inputPlaceholder: string;
|
||||
placeholder: string;
|
||||
@@ -62,7 +62,7 @@ function readTextPaste(event: PasteEvent): string | null {
|
||||
export function InputBar(props: InputBarProps) {
|
||||
const {
|
||||
accent,
|
||||
ruleColor,
|
||||
inputBackground,
|
||||
inputForeground,
|
||||
inputPlaceholder,
|
||||
placeholder,
|
||||
@@ -197,13 +197,13 @@ export function InputBar(props: InputBarProps) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
border={["top", "bottom"]}
|
||||
borderStyle="single"
|
||||
borderColor={ruleColor}
|
||||
backgroundColor={inputBackground}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
onMouseDown={props.onFocusRequest}
|
||||
>
|
||||
<text fg={accent}>
|
||||
<strong>{"❯"}</strong>
|
||||
<strong>{">"}</strong>
|
||||
</text>
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<textarea
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: ClineModelPickerTier;
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
|
||||
ClineModelPickerTier,
|
||||
string
|
||||
> = {
|
||||
recommended: "Recommended",
|
||||
subscribed: "Subscribed",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
// Featured entries for the sectioned picker, keyed by provider: cline gets
|
||||
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
|
||||
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
|
||||
export function buildFeaturedModelEntries(
|
||||
providerId: string,
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
return providerId === "cline-pass"
|
||||
? buildClinePassModelEntries(data)
|
||||
: buildClineModelEntries(data);
|
||||
}
|
||||
|
||||
function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Shown under the Free section header when picking a model for ClinePass
|
||||
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
|
||||
"Try with limited usage, separate from ClinePass quota.";
|
||||
|
||||
// ClinePass shows the subscription's models plus the Cline free models — both
|
||||
// providers hit the same Cline API, so free models are selectable in place
|
||||
// (they ride usage billing at $0 instead of the subscription quota).
|
||||
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
|
||||
// the ClinePass catalog contains exactly these two buckets, so the sections
|
||||
// already list every selectable model. An empty clinePass bucket means the
|
||||
// fetch fell back to the bundled list (which has no pass models) — without an
|
||||
// escape into the full catalog a subscriber could only pick free models, so
|
||||
// browse-all comes back in that degraded mode.
|
||||
function buildClinePassModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.clinePass) {
|
||||
entries.push({ kind: "model", model: m, tier: "subscribed" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
if (data.clinePass.length === 0) {
|
||||
entries.push({ kind: "browse" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The quota explainer only makes sense in the ClinePass picker, which is the
|
||||
// only picker that has a "subscribed" section
|
||||
export function freeTierDescriptionFor(
|
||||
entries: ClineModelPickerEntry[],
|
||||
): string | undefined {
|
||||
const isClinePassPicker = entries.some(
|
||||
(entry) => entry.kind === "model" && entry.tier === "subscribed",
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
|
||||
describe("cline model picker entries", () => {
|
||||
it("builds Recommended/Free sections for the cline provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("anthropic/claude-sonnet-5"),
|
||||
tier: "recommended",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds Subscribed/Free sections for the cline-pass provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
|
||||
{
|
||||
kind: "model",
|
||||
model: model("cline-pass/kimi-k2.6"),
|
||||
tier: "subscribed",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds the browse-all escape when the clinePass bucket is empty", () => {
|
||||
// The fetch fell back to the bundled list (no pass models); the sections
|
||||
// alone would leave a subscriber able to pick only free models.
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
|
||||
const data = {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
|
||||
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
@@ -8,28 +9,25 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
export {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerBrowse,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
@@ -41,13 +39,12 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
const fallback = modelId.includes("/")
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
@@ -71,6 +68,20 @@ export function useClineRecommendedModels() {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function ClineModelPicker(props: {
|
||||
entries: ClineModelPickerEntry[];
|
||||
selected: number;
|
||||
@@ -92,7 +103,6 @@ export function ClineModelPicker(props: {
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const rows: ReactNode[] = [];
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -102,20 +112,14 @@ export function ClineModelPicker(props: {
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
rows.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
{entry.tier === "free" && freeTierDescription && (
|
||||
<text fg="gray">
|
||||
<em>{freeTierDescription}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
|
||||
@@ -3,12 +3,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
@@ -22,7 +17,7 @@ type ClineModelEntriesState =
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
@@ -34,13 +29,12 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
const fallback = modelId.includes("/")
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
@@ -68,13 +62,11 @@ export function ClineModelSelectorContent(
|
||||
key: string;
|
||||
kind: "header" | "model" | "browse";
|
||||
label: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}[] = [];
|
||||
let lastTier: string | null = null;
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
@@ -84,9 +76,7 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
|
||||
description:
|
||||
entry.tier === "free" ? freeTierDescription : undefined,
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
@@ -166,18 +156,8 @@ export function ClineModelSelectorContent(
|
||||
if (row.kind === "header") {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
marginTop={isFirst ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<text fg="gray">{row.label}</text>
|
||||
{row.description && (
|
||||
<text fg="gray">
|
||||
<em>{row.description}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -292,7 +272,7 @@ export function ClineModelSelectorDialogContent(
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.act}>Choose a model</text>
|
||||
<text fg="cyan">Choose a model</text>
|
||||
<ProviderRow providerName={props.currentProviderName} focused={false} />
|
||||
<text fg="red">{state.message}</text>
|
||||
<text fg="gray">R to retry, Esc to go back</text>
|
||||
@@ -302,7 +282,7 @@ export function ClineModelSelectorDialogContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.act}>Choose a model</text>
|
||||
<text fg="cyan">Choose a model</text>
|
||||
<ProviderRow providerName={props.currentProviderName} focused={false} />
|
||||
<text fg="gray">{state.message}</text>
|
||||
<text fg="gray">Esc to go back</text>
|
||||
|
||||
@@ -329,8 +329,7 @@ export function ThinkingLevelContent(
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === currentLevel);
|
||||
return idx >= 0 ? idx : 0;
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ProviderRow({
|
||||
<text fg={focused ? palette.selection : "gray"} flexShrink={0}>
|
||||
{focused ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focused ? palette.selection : palette.act} flexShrink={0}>
|
||||
<text fg={focused ? palette.selection : "cyan"} flexShrink={0}>
|
||||
Provider:
|
||||
</text>
|
||||
<text fg="white">{providerName}</text>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
createContextBar,
|
||||
formatStatusBarUsageText,
|
||||
resolveContextBarFilledForeground,
|
||||
resolveModelDisplayName,
|
||||
} from "./status-bar";
|
||||
|
||||
vi.mock("@opentui/react", () => ({
|
||||
@@ -14,14 +13,14 @@ describe("createContextBar", () => {
|
||||
it("keeps a stable width while changing segment lengths", () => {
|
||||
expect(createContextBar(0, 100)).toEqual({
|
||||
filled: "",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(50, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(100, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
@@ -29,17 +28,17 @@ describe("createContextBar", () => {
|
||||
it("shows a non-empty fill when usage is above zero", () => {
|
||||
expect(createContextBar(7_000, 1_000_000)).toEqual({
|
||||
filled: "\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves the final segment for usage at or above the limit", () => {
|
||||
expect(createContextBar(999_999, 1_000_000)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588",
|
||||
});
|
||||
expect(createContextBar(1_000_000, 1_000_000)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
@@ -56,77 +55,18 @@ describe("formatStatusBarUsageText", () => {
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
providerId: "cline",
|
||||
showCost: true,
|
||||
}),
|
||||
).toBe("(12,345) $0.12");
|
||||
});
|
||||
|
||||
it("rounds cost to two decimals even when tiny", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.0004,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345) $0.00");
|
||||
});
|
||||
|
||||
it("hides cost entirely for subscription providers", () => {
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
providerId: "cline-pass",
|
||||
showCost: false,
|
||||
}),
|
||||
).toBe("(12,345)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelDisplayName", () => {
|
||||
it("uses the friendly model name with a ClinePass prefix", () => {
|
||||
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("falls back to the bare model id with a ClinePass prefix when unknown", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
}),
|
||||
).toBe("ClinePass: glm-5.2");
|
||||
});
|
||||
|
||||
it("keeps the reasoning effort next to the model name", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toBe("ClinePass: GLM 5.2 (high)");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import {
|
||||
shouldShowCliUsageCost,
|
||||
shouldShowCliUsageCoveredBySubscription,
|
||||
} from "../../utils/usage-cost-display";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
@@ -18,7 +15,7 @@ import { HOME_VIEW_MAX_WIDTH } from "../types";
|
||||
export function createContextBar(
|
||||
used: number,
|
||||
total?: number,
|
||||
width = 6,
|
||||
width = 8,
|
||||
): { filled: string; empty: string } {
|
||||
const normalizedWidth = Math.max(0, Math.floor(width));
|
||||
const ratio = total && total > 0 ? Math.min(used / total, 1) : 0;
|
||||
@@ -45,35 +42,18 @@ export function resolveContextBarFilledForeground(
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatCostText(providerId: string, totalCost: number): string {
|
||||
// Subscription providers (ClinePass) have no per-use cost worth surfacing.
|
||||
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!shouldShowCliUsageCost(providerId)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return formatCost(totalCost);
|
||||
}
|
||||
|
||||
export function formatStatusBarUsageText(input: {
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
providerId: string;
|
||||
showCost: boolean;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
const costText = formatCostText(input.providerId, input.totalCost);
|
||||
|
||||
if (!costText) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
return `${tokens} ${costText}`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
}
|
||||
|
||||
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
|
||||
@@ -94,22 +74,17 @@ 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 modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
|
||||
let displayName = info?.name ?? modelIdTail;
|
||||
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
|
||||
if (config.thinking && config.reasoningEffort) {
|
||||
displayName = `${displayName} (${config.reasoningEffort})`;
|
||||
return `${name} (${config.reasoningEffort})`;
|
||||
}
|
||||
if (config.providerId === "cline-pass") {
|
||||
displayName = `ClinePass: ${displayName}`;
|
||||
}
|
||||
return displayName;
|
||||
return name;
|
||||
}
|
||||
|
||||
export function resolveModelMaxInputTokens(config: {
|
||||
@@ -177,6 +152,7 @@ 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).
|
||||
@@ -193,7 +169,7 @@ export function StatusBar(props: StatusBarProps) {
|
||||
const usageText = formatStatusBarUsageText({
|
||||
totalTokens,
|
||||
totalCost,
|
||||
providerId: props.providerId,
|
||||
showCost: showUsageCost,
|
||||
});
|
||||
const contextText = bar
|
||||
? ` ${bar.filled}${bar.empty} ${usageText}`
|
||||
|
||||
@@ -103,16 +103,9 @@ export function SessionProvider(props: {
|
||||
const [hasSubmitted, setHasSubmitted] = useState(
|
||||
(initialEntries?.length ?? 0) > 0,
|
||||
);
|
||||
const [uiMode, _setUiMode] = useState<AgentMode>(
|
||||
const [uiMode, setUiMode] = useState<AgentMode>(
|
||||
config.mode === "plan" ? "plan" : "act",
|
||||
);
|
||||
// Mirror for appendEntry: entries are appended from event-handler
|
||||
// callbacks that must see the mode at append time, not at closure time.
|
||||
const uiModeRef = useRef<AgentMode>(config.mode === "plan" ? "plan" : "act");
|
||||
const setUiMode = useCallback((mode: AgentMode) => {
|
||||
uiModeRef.current = mode;
|
||||
_setUiMode(mode);
|
||||
}, []);
|
||||
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
|
||||
const autoApproveAllRef = useRef(initialAutoApproveAll);
|
||||
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
|
||||
@@ -139,9 +132,8 @@ export function SessionProvider(props: {
|
||||
);
|
||||
|
||||
const appendEntry = useCallback((entry: ChatEntry) => {
|
||||
const stamped = entry.mode ? entry : { ...entry, mode: uiModeRef.current };
|
||||
setEntries((prev) => {
|
||||
const next = [...prev, stamped];
|
||||
const next = [...prev, entry];
|
||||
return next.length <= MAX_BUFFERED_LINES
|
||||
? next
|
||||
: next.slice(next.length - MAX_BUFFERED_LINES);
|
||||
@@ -196,8 +188,8 @@ export function SessionProvider(props: {
|
||||
}, []);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setUiMode(uiModeRef.current === "act" ? "plan" : "act");
|
||||
}, [setUiMode]);
|
||||
setUiMode((m) => (m === "act" ? "plan" : "act"));
|
||||
}, []);
|
||||
|
||||
const toggleAutoApprove = useCallback(() => {
|
||||
const next = !autoApproveAllRef.current;
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
type AccountDialogAction,
|
||||
AccountDialogContent,
|
||||
} from "../components/dialogs/account-dialog";
|
||||
import {
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export function useAccountDialog(opts: {
|
||||
@@ -63,14 +60,14 @@ export function useAccountDialog(opts: {
|
||||
return;
|
||||
}
|
||||
if (action === "login") {
|
||||
const saved = await dialog.choice<OAuthLoginResult>({
|
||||
const saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
|
||||
),
|
||||
});
|
||||
if (saved === true) {
|
||||
if (saved) {
|
||||
await onAccountChange?.();
|
||||
await openAccountDialog();
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
@@ -297,13 +296,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const handlePendingPromptSubmitted = useCallback(
|
||||
(event: PendingPromptSubmittedEvent) => {
|
||||
knownPendingPromptIdsRef.current.delete(event.id);
|
||||
// Display boundary: formatDisplayUserInput strips runtime-generated
|
||||
// notice elements (e.g. mode_notice) that normalizeUserInput must
|
||||
// preserve, since the latter also sanitizes model-bound prompts.
|
||||
appendEntry({
|
||||
kind: "user_submitted",
|
||||
text: formatDisplayUserInput(event.prompt),
|
||||
});
|
||||
appendEntry({ kind: "user_submitted", text: event.prompt });
|
||||
},
|
||||
[appendEntry],
|
||||
);
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("formatCompactionStatus", () => {
|
||||
messagesAfter: 300,
|
||||
compacted: true,
|
||||
}),
|
||||
).toBe("Compacted context; message count stayed at 300 messages.");
|
||||
).toBe("Compacted context; message count stayed at 300.");
|
||||
});
|
||||
|
||||
it("reports empty sessions separately", () => {
|
||||
|
||||
@@ -75,10 +75,9 @@ export function useLocalCommandActions(input: {
|
||||
});
|
||||
} else {
|
||||
session.clearEntries();
|
||||
// replaceEntries rather than appendEntry: appendEntry
|
||||
// stamps unstamped entries with the CURRENT mode, which
|
||||
// would lock hydrated history to the resume-time accent.
|
||||
session.replaceEntries(entries);
|
||||
for (const entry of entries) {
|
||||
session.appendEntry(entry);
|
||||
}
|
||||
if (typeof result.currentContextSize === "number") {
|
||||
session.setLastTotalTokens(result.currentContextSize);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
refreshProviderModelsFromSource,
|
||||
resolveProviderConfig,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
@@ -19,17 +18,14 @@ import {
|
||||
import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
OAuthApiKeyInputContent,
|
||||
type ExistingProviderAction,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
UseExistingOrReconfigureContent,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
@@ -82,36 +78,6 @@ 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,
|
||||
@@ -134,73 +100,32 @@ async function runProviderChange(
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
|
||||
// Manual API key entry is the escape hatch for when OAuth login isn't
|
||||
// working; only the Cline providers accept a dashboard API key.
|
||||
const supportsManualApiKey = isClineProvider(newProviderId);
|
||||
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
|
||||
await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<OAuthApiKeyInputContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
providerSettingsManager={manager}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
let needsAuth = true;
|
||||
if (isProviderConfigured(newProviderId, existingSettings)) {
|
||||
let option: ExistingProviderOption | undefined;
|
||||
const extraOptions = providerToExistingProviderOptions({
|
||||
providerId: newProviderId,
|
||||
providerName: displayName,
|
||||
dialog,
|
||||
termHeight,
|
||||
const action = await dialog.choice<ExistingProviderAction>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
|
||||
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
|
||||
),
|
||||
});
|
||||
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 (!action) return false;
|
||||
needsAuth = action === "reconfigure";
|
||||
}
|
||||
|
||||
if (needsAuth) {
|
||||
let saved: boolean | undefined;
|
||||
if (isOAuthProvider(newProviderId)) {
|
||||
const loginResult = await dialog.choice<OAuthLoginResult>({
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<OAuthLoginContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
allowApiKeyFallback={supportsManualApiKey}
|
||||
/>
|
||||
),
|
||||
});
|
||||
saved =
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
@@ -366,13 +291,7 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
config.providerId === "cline" ||
|
||||
config.providerId === "cline-pass"
|
||||
) {
|
||||
// ClinePass gets the same sectioned picker with Subscribed/Free
|
||||
// sections — free models are selectable while staying on ClinePass
|
||||
const featuredProviderId = config.providerId;
|
||||
if (config.providerId === "cline") {
|
||||
const clineResult = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -382,10 +301,7 @@ export function useModelSelector(opts: {
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildFeaturedModelEntries(
|
||||
featuredProviderId,
|
||||
await fetchClineRecommendedModels(),
|
||||
)
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -23,15 +23,15 @@ describe("getTerminalTheme", () => {
|
||||
});
|
||||
|
||||
describe("theme-aware palette helpers", () => {
|
||||
it("uses the brand accent colors for dark terminals", () => {
|
||||
expect(getModeAccent("act", "dark")).toBe("#79b8ff");
|
||||
expect(getModeAccent("plan", "dark")).toBe("#ffea7f");
|
||||
expect(getSuccessColor("dark")).toBe("#99e89b");
|
||||
it("preserves the existing named ANSI colors for dark terminals", () => {
|
||||
expect(getModeAccent("act", "dark")).toBe("cyan");
|
||||
expect(getModeAccent("plan", "dark")).toBe("yellow");
|
||||
expect(getSuccessColor("dark")).toBe("brightGreen");
|
||||
});
|
||||
|
||||
it("uses darker accents on light terminals", () => {
|
||||
expect(getModeAccent("act", "light")).toBe("#0f72cb");
|
||||
expect(getModeAccent("plan", "light")).toBe("#867100");
|
||||
expect(getModeAccent("act", "light")).toBe("#0969da");
|
||||
expect(getModeAccent("plan", "light")).toBe("#9a6700");
|
||||
expect(getSuccessColor("light")).toBe("#116329");
|
||||
});
|
||||
});
|
||||
|
||||
+19
-52
@@ -1,9 +1,9 @@
|
||||
export const palette = {
|
||||
act: "#79b8ff",
|
||||
plan: "#ffea7f",
|
||||
selection: "#79b8ff",
|
||||
act: "cyan",
|
||||
plan: "yellow",
|
||||
selection: "cyan",
|
||||
error: "red",
|
||||
success: "#99e89b",
|
||||
success: "brightGreen",
|
||||
muted: "gray",
|
||||
textOnSelection: "black",
|
||||
} as const;
|
||||
@@ -16,11 +16,9 @@ export const themePalette = {
|
||||
plan: palette.plan,
|
||||
success: palette.success,
|
||||
},
|
||||
// Same OKLCH hues as the dark accents, darkened to hold >=4.5:1 contrast
|
||||
// on white so the plan/act identity carries across themes.
|
||||
light: {
|
||||
act: "#0f72cb",
|
||||
plan: "#867100",
|
||||
act: "#0969da",
|
||||
plan: "#9a6700",
|
||||
success: "#116329",
|
||||
},
|
||||
} as const;
|
||||
@@ -31,7 +29,7 @@ export const diffPalettes = {
|
||||
removedBg: "#4d1a1a",
|
||||
addedLineNumberBg: "#1a4d1a",
|
||||
removedLineNumberBg: "#4d1a1a",
|
||||
addedSignColor: "#99e89b",
|
||||
addedSignColor: "#22c55e",
|
||||
removedSignColor: "#ef4444",
|
||||
lineNumberFg: "#888888",
|
||||
},
|
||||
@@ -77,8 +75,8 @@ export function getSuccessColor(theme: TerminalTheme = "dark"): string {
|
||||
// overshoot.
|
||||
// 3. On dark themes, raise L (lighten). On light themes, lower L (darken).
|
||||
// 4. Nudge the a/b chromatic channels by CHROMA_NUDGE toward the mode's
|
||||
// accent color. For plan (warm/yellow): +a, +b. For act (cool/blue):
|
||||
// -a, -b. At 0.003 this is ~10x below OKLAB's just-noticeable-difference
|
||||
// accent color. For plan (warm/yellow): +a, +b. For act (cool/cyan):
|
||||
// -a, +b. At 0.003 this is ~10x below OKLAB's just-noticeable-difference
|
||||
// threshold (~0.03), so it registers as a "feel" rather than visible color.
|
||||
//
|
||||
// Sample outputs on common terminals (act mode / plan mode bg):
|
||||
@@ -133,53 +131,22 @@ export function getDefaultForeground(
|
||||
return isLightTheme(terminalBg) ? "#1a1a1a" : undefined;
|
||||
}
|
||||
|
||||
function liftedFromTerminalBg(
|
||||
terminalBg: string | null,
|
||||
baseLift: number,
|
||||
nudgeA: number,
|
||||
nudgeB: number,
|
||||
): string {
|
||||
const hex = normalizeHex(terminalBg) ?? "#000000";
|
||||
const base = hexToOklab(hex);
|
||||
const light = base.L > LIGHT_THEME_THRESHOLD;
|
||||
const lift = baseLift / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
|
||||
return oklabToHex(
|
||||
base.L + (light ? -lift : lift),
|
||||
base.a + nudgeA,
|
||||
base.b + nudgeB,
|
||||
);
|
||||
}
|
||||
|
||||
export function getModeInputBackground(
|
||||
mode: string,
|
||||
terminalBg: string | null,
|
||||
): string {
|
||||
const hex = normalizeHex(terminalBg) ?? "#000000";
|
||||
const base = hexToOklab(hex);
|
||||
const light = base.L > LIGHT_THEME_THRESHOLD;
|
||||
const lift = BASE_LIFT / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
|
||||
const warm = mode === "plan";
|
||||
return liftedFromTerminalBg(
|
||||
terminalBg,
|
||||
BASE_LIFT,
|
||||
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
|
||||
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
|
||||
return oklabToHex(
|
||||
base.L + (light ? -lift : lift),
|
||||
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + CHROMA_NUDGE,
|
||||
);
|
||||
}
|
||||
|
||||
// The `─` rules framing the input field are thin foreground strokes rather
|
||||
// than filled cells, so they need a much larger lift than a background tint
|
||||
// to register at the same perceptual weight — this lands them around mid-gray
|
||||
// on both black and white terminals. They stay neutral (no mode chroma) so
|
||||
// the frame doesn't shift color when toggling plan/act.
|
||||
const RULE_BASE_LIFT = 0.5;
|
||||
|
||||
export function getInputRuleColor(terminalBg: string | null): string {
|
||||
return liftedFromTerminalBg(terminalBg, RULE_BASE_LIFT, 0, 0);
|
||||
}
|
||||
|
||||
// User message bubbles stay neutral (no mode chroma) so the transcript reads
|
||||
// as history rather than tracking whichever mode is currently active.
|
||||
export function getUserMessageBackground(terminalBg: string | null): string {
|
||||
return liftedFromTerminalBg(terminalBg, BASE_LIFT, 0, 0);
|
||||
}
|
||||
|
||||
export function getModeInputForeground(
|
||||
mode: string,
|
||||
terminalBg: string | null,
|
||||
@@ -190,7 +157,7 @@ export function getModeInputForeground(
|
||||
return oklabToHex(
|
||||
base.L,
|
||||
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + CHROMA_NUDGE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,7 +171,7 @@ export function getModeInputPlaceholder(
|
||||
return oklabToHex(
|
||||
base.L,
|
||||
base.a + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
|
||||
base.b + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
|
||||
base.b + CHROMA_NUDGE * 2,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -401,10 +400,9 @@ function App(props: TuiProps) {
|
||||
if (lastEntry && lastEntry.kind === "user_submitted") {
|
||||
entries.pop();
|
||||
}
|
||||
// replaceEntries rather than appendEntry: appendEntry stamps
|
||||
// unstamped entries with the CURRENT mode, which would lock
|
||||
// hydrated history to the restore-time accent.
|
||||
session.replaceEntries(entries);
|
||||
for (const entry of entries) {
|
||||
session.appendEntry(entry);
|
||||
}
|
||||
session.setHasSubmitted(entries.length > 0);
|
||||
setAppView(entries.length > 0 ? "chat" : "home");
|
||||
populateInputRef.current(picked.fullText);
|
||||
@@ -543,17 +541,10 @@ 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(() => {
|
||||
@@ -569,7 +560,7 @@ function App(props: TuiProps) {
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
|
||||
}, [appView, dialog, notice, onInitialNoticeShown]);
|
||||
|
||||
const {
|
||||
appendEntry: appendSessionEntry,
|
||||
@@ -888,7 +879,6 @@ function App(props: TuiProps) {
|
||||
repoStatus,
|
||||
textareaRef: promptInput.textareaRef,
|
||||
transcriptScrollRef,
|
||||
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
|
||||
queuedPrompts,
|
||||
selectedQueuedPromptId,
|
||||
editingQueuedPrompt,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AgentEvent,
|
||||
AgentMode,
|
||||
CheckpointEntry,
|
||||
ClineSubscriptionPlan,
|
||||
TeamEvent,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -25,7 +24,7 @@ import type {
|
||||
} from "./interactive-config";
|
||||
import type { InteractiveSlashCommand } from "./interactive-welcome";
|
||||
|
||||
export type ChatEntry = (
|
||||
export type ChatEntry =
|
||||
| { kind: "user"; text: string }
|
||||
| { kind: "assistant_text"; text: string; streaming: boolean }
|
||||
| { kind: "reasoning"; text: string; streaming: boolean }
|
||||
@@ -52,17 +51,7 @@ export type ChatEntry = (
|
||||
cost: number;
|
||||
elapsed: string;
|
||||
iterations: number;
|
||||
}
|
||||
) & {
|
||||
/**
|
||||
* Agent mode active when the entry was produced. Stamped by appendEntry
|
||||
* (live sessions) and hydrateSessionMessages (resumed sessions) so the
|
||||
* transcript renders each entry with the accent of its own mode instead
|
||||
* of retinting everything to the current mode. Absent on entries from
|
||||
* transcripts that predate mode stamping.
|
||||
*/
|
||||
mode?: AgentMode;
|
||||
};
|
||||
};
|
||||
|
||||
export interface InteractiveTurnResult {
|
||||
usage: {
|
||||
@@ -90,7 +79,6 @@ export interface ResumedSessionResult {
|
||||
export interface InteractiveCompactionResult {
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
@@ -141,7 +129,6 @@ export interface TuiProps {
|
||||
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
|
||||
loadWelcomeLine?: () => Promise<string | undefined>;
|
||||
loadClineAccount: () => Promise<ClineAccountSnapshot>;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
switchClineAccount: (organizationId?: string | null) => Promise<void>;
|
||||
loadConfigData: (
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
@@ -13,11 +9,8 @@ export function formatCompactionStatus(
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
|
||||
describe("hydrateSessionMessages", () => {
|
||||
it("renders regular user messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "lets do it", mode: "plan" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the synthetic act-mode continuation prompt", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "On it.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "assistant_text", text: "On it.", streaming: false, mode: "act" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("stamps entries with the mode of the user message that produced them", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">plan this out</user_input>',
|
||||
},
|
||||
{ role: "assistant", content: "Here is the plan." },
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="act">do it</user_input>',
|
||||
},
|
||||
{ role: "assistant", content: "Doing it." },
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "plan this out", mode: "plan" },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Here is the plan.",
|
||||
streaming: false,
|
||||
mode: "plan",
|
||||
},
|
||||
{ kind: "user_submitted", text: "do it", mode: "act" },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Doing it.",
|
||||
streaming: false,
|
||||
mode: "act",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to act mode after a switch_to_act_mode tool call", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">plan then build</user_input>',
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Plan looks good, switching." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "switch_to_act_mode",
|
||||
input: {},
|
||||
},
|
||||
{ type: "text", text: "Building now." },
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "plan then build", mode: "plan" },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Plan looks good, switching.",
|
||||
streaming: false,
|
||||
mode: "plan",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolName: "switch_to_act_mode",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: {},
|
||||
streaming: false,
|
||||
mode: "plan",
|
||||
},
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Building now.",
|
||||
streaming: false,
|
||||
mode: "act",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips mode switch notices from displayed user text", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "are you okay?", mode: "plan" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves mode undefined for transcripts without user_input wrappers", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "plain old message" },
|
||||
{ role: "assistant", content: "reply" },
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "plain old message", mode: undefined },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "reply",
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
parseUserInputMode,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatDisplayUserInput, type Message } from "@cline/shared";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
@@ -17,12 +11,6 @@ function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
// The act-mode continuation prompt is runtime-generated, not typed by the
|
||||
// user, so it should not surface as a user bubble in the transcript.
|
||||
function isSyntheticUserText(text: string): boolean {
|
||||
return text === ACT_MODE_CONTINUATION_PROMPT;
|
||||
}
|
||||
|
||||
function stringifyToolResult(
|
||||
content: string | Array<{ type: string; text?: string; path?: string }>,
|
||||
): string {
|
||||
@@ -42,12 +30,6 @@ function stringifyToolResult(
|
||||
export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
const entries: ChatEntry[] = [];
|
||||
const toolUseMap = new Map<string, number>();
|
||||
// Mode each entry was produced in, recovered from <user_input mode="...">
|
||||
// wrappers and switch_to_act_mode tool calls as we walk the transcript.
|
||||
// Stays undefined for transcripts with no mode markers (pre-wrapper
|
||||
// builds, or transcripts laundered by older builds that stripped the
|
||||
// wrappers on session restarts).
|
||||
let mode: AgentMode | undefined;
|
||||
|
||||
for (const msg of messages as PersistedMessage[]) {
|
||||
const displayRole = getDisplayRole(msg);
|
||||
@@ -57,17 +39,13 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.role === "user") {
|
||||
mode = parseUserInputMode(msg.content) ?? mode;
|
||||
const text = formatDisplayUserInput(msg.content);
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text, mode });
|
||||
}
|
||||
if (text) entries.push({ kind: "user_submitted", text });
|
||||
} else {
|
||||
entries.push({
|
||||
kind: "assistant_text",
|
||||
text: msg.content,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -84,7 +62,6 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
kind: "assistant_text",
|
||||
text: block.text,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -95,7 +72,6 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
kind: "reasoning",
|
||||
text: block.thinking,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -111,14 +87,8 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
inputSummary: formatToolInput(block.name, block.input),
|
||||
rawInput: block.input,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
toolUseMap.set(block.id, entries.length - 1);
|
||||
// The switch tool flips the session to act mid-run; everything
|
||||
// after it was produced in act mode.
|
||||
if (block.name === "switch_to_act_mode") {
|
||||
mode = "act";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -144,10 +114,9 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
|
||||
if (msg.role === "user" && userTextParts.length > 0) {
|
||||
const combined = userTextParts.join("\n");
|
||||
mode = parseUserInputMode(combined) ?? mode;
|
||||
const text = formatDisplayUserInput(combined);
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text, mode });
|
||||
if (text) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,33 +49,4 @@ describe("getSyntaxStyle", () => {
|
||||
|
||||
expect(style?.fg?.toInts()).toEqual([26, 26, 26, 255]);
|
||||
});
|
||||
|
||||
it("tints markdown accents by mode", () => {
|
||||
// act #79b8ff vs plan #ffea7f (dark theme accents)
|
||||
expect(
|
||||
getSyntaxStyle("dark", "act").getStyle("markup.heading")?.fg?.toInts(),
|
||||
).toEqual([0x79, 0xb8, 0xff, 255]);
|
||||
expect(
|
||||
getSyntaxStyle("dark", "plan").getStyle("markup.heading")?.fg?.toInts(),
|
||||
).toEqual([0xff, 0xea, 0x7f, 255]);
|
||||
expect(
|
||||
getSyntaxStyle("dark", "plan").getStyle("markup.link")?.fg?.toInts(),
|
||||
).toEqual([0xff, 0xea, 0x7f, 255]);
|
||||
});
|
||||
|
||||
it("tints light-theme markdown accents by mode", () => {
|
||||
// act #0f72cb vs plan #867100 (light theme accents)
|
||||
expect(
|
||||
getSyntaxStyle("light", "act").getStyle("markup.heading")?.fg?.toInts(),
|
||||
).toEqual([0x0f, 0x72, 0xcb, 255]);
|
||||
expect(
|
||||
getSyntaxStyle("light", "plan").getStyle("markup.heading")?.fg?.toInts(),
|
||||
).toEqual([0x86, 0x71, 0x00, 255]);
|
||||
});
|
||||
|
||||
it("keeps code token colors constant across modes", () => {
|
||||
expect(getSyntaxStyle("dark", "plan").getStyle("keyword")).toEqual(
|
||||
getSyntaxStyle("dark", "act").getStyle("keyword"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
|
||||
import { type TerminalTheme, themePalette } from "../palette";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
|
||||
// Markdown's prominent elements (headings, bold, list markers, links) take
|
||||
// the accent of the mode the content was produced in, so assistant output
|
||||
// reads plan-yellow or act-blue alongside the rest of the transcript.
|
||||
export type SyntaxAccentMode = "act" | "plan";
|
||||
|
||||
const instances = new Map<string, SyntaxStyle>();
|
||||
const instances: Record<TerminalTheme, SyntaxStyle | null> = {
|
||||
dark: null,
|
||||
light: null,
|
||||
};
|
||||
|
||||
interface SyntaxColors {
|
||||
keyword: string;
|
||||
@@ -24,34 +22,34 @@ interface SyntaxColors {
|
||||
attribute: string;
|
||||
escape: string;
|
||||
markdownCode: string;
|
||||
markdownHeading: string;
|
||||
markdownMuted: string;
|
||||
markdownLink: string;
|
||||
markdownItalic: string;
|
||||
markdownDefault?: string;
|
||||
}
|
||||
|
||||
// Dark syntax colors are a pastel family harmonized with the brand accents
|
||||
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
|
||||
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
|
||||
// part of the same palette instead of a bolted-on editor theme.
|
||||
const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
|
||||
dark: {
|
||||
keyword: "#d7a0e3",
|
||||
operator: "#9bbbdd",
|
||||
type: "#dfca7d",
|
||||
functionName: themePalette.dark.act,
|
||||
variable: "#ee939b",
|
||||
string: "#99e89b",
|
||||
number: "#f0ad7f",
|
||||
keyword: "#c678dd",
|
||||
operator: "#56b6c2",
|
||||
type: "#e5c07b",
|
||||
functionName: "#61afef",
|
||||
variable: "#e06c75",
|
||||
string: "#98c379",
|
||||
number: "#d19a66",
|
||||
comment: "#5c6370",
|
||||
punctuation: "#abb2bf",
|
||||
property: "#ee939b",
|
||||
constant: "#f0ad7f",
|
||||
tag: "#ee939b",
|
||||
attribute: "#f0ad7f",
|
||||
escape: "#9bbbdd",
|
||||
markdownCode: "#99e89b",
|
||||
property: "#e06c75",
|
||||
constant: "#d19a66",
|
||||
tag: "#e06c75",
|
||||
attribute: "#d19a66",
|
||||
escape: "#56b6c2",
|
||||
markdownCode: "#98c379",
|
||||
markdownHeading: "#56b6c2",
|
||||
markdownMuted: "#808080",
|
||||
markdownItalic: "#dfca7d",
|
||||
markdownLink: "#56b6c2",
|
||||
markdownItalic: "#e5c07b",
|
||||
},
|
||||
light: {
|
||||
keyword: "#cf222e",
|
||||
@@ -69,7 +67,9 @@ const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
|
||||
attribute: "#0550ae",
|
||||
escape: "#0550ae",
|
||||
markdownCode: "#116329",
|
||||
markdownHeading: "#0969da",
|
||||
markdownMuted: "#6e7781",
|
||||
markdownLink: "#0969da",
|
||||
markdownItalic: "#8250df",
|
||||
markdownDefault: "#1a1a1a",
|
||||
},
|
||||
@@ -91,16 +91,16 @@ function italic(hex: string): StyleDefinition {
|
||||
return { fg: color(hex), italic: true };
|
||||
}
|
||||
|
||||
function buildSyntaxStyle(
|
||||
theme: TerminalTheme,
|
||||
mode: SyntaxAccentMode,
|
||||
): SyntaxStyle {
|
||||
function underline(hex: string): StyleDefinition {
|
||||
return { fg: color(hex), underline: true };
|
||||
}
|
||||
|
||||
function buildSyntaxStyle(theme: TerminalTheme): SyntaxStyle {
|
||||
const colors = syntaxColors[theme];
|
||||
const accent = color(themePalette[theme][mode]);
|
||||
const markdownHeading = accent;
|
||||
const markdownHeading = color(colors.markdownHeading);
|
||||
const markdownCode = color(colors.markdownCode);
|
||||
const markdownMuted = color(colors.markdownMuted);
|
||||
const markdownLink = accent;
|
||||
const markdownLink = color(colors.markdownLink);
|
||||
|
||||
return SyntaxStyle.fromStyles({
|
||||
...(colors.markdownDefault ? { default: fg(colors.markdownDefault) } : {}),
|
||||
@@ -145,19 +145,10 @@ function buildSyntaxStyle(
|
||||
"markup.link.url": { fg: markdownLink, underline: true },
|
||||
label: { fg: markdownLink },
|
||||
conceal: { fg: markdownMuted },
|
||||
"string.special.url": { fg: markdownLink, underline: true },
|
||||
"string.special.url": underline(colors.markdownLink),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSyntaxStyle(
|
||||
theme: TerminalTheme = "dark",
|
||||
mode: SyntaxAccentMode = "act",
|
||||
): SyntaxStyle {
|
||||
const key = `${theme}:${mode}`;
|
||||
let style = instances.get(key);
|
||||
if (!style) {
|
||||
style = buildSyntaxStyle(theme, mode);
|
||||
instances.set(key, style);
|
||||
}
|
||||
return style;
|
||||
export function getSyntaxStyle(theme: TerminalTheme = "dark"): SyntaxStyle {
|
||||
return (instances[theme] ??= buildSyntaxStyle(theme));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
useTerminalTheme,
|
||||
} from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getInputRuleColor,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
getModeInputForeground,
|
||||
@@ -51,7 +50,6 @@ export function ChatView(props: {
|
||||
};
|
||||
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
|
||||
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
|
||||
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
|
||||
autocomplete?: AutocompleteDropdownProps;
|
||||
queuedPrompts?: QueuedPromptItem[];
|
||||
selectedQueuedPromptId?: string | null;
|
||||
@@ -77,7 +75,6 @@ export function ChatView(props: {
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const accent = getModeAccent(session.uiMode, terminalTheme);
|
||||
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
|
||||
const inputRuleColor = getInputRuleColor(terminalBg);
|
||||
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
|
||||
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
|
||||
const placeholder =
|
||||
@@ -92,7 +89,6 @@ export function ChatView(props: {
|
||||
ref={props.transcriptScrollRef}
|
||||
entries={session.entries}
|
||||
isStreaming={session.isStreaming}
|
||||
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
|
||||
uiMode={session.uiMode}
|
||||
/>
|
||||
|
||||
@@ -125,10 +121,10 @@ export function ChatView(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<box>
|
||||
<box marginBottom={1}>
|
||||
<InputBar
|
||||
accent={accent}
|
||||
ruleColor={inputRuleColor}
|
||||
inputBackground={inputBackground}
|
||||
inputForeground={inputForeground}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -721,7 +721,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>Settings</strong>
|
||||
</text>
|
||||
|
||||
@@ -793,7 +793,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={isSel ? palette.act : undefined}>{pfx}Provider</text>
|
||||
<text fg={isSel ? "cyan" : undefined}>{pfx}Provider</text>
|
||||
<text fg="white">{props.providerDisplayName}</text>
|
||||
</box>
|
||||
);
|
||||
@@ -804,7 +804,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={isSel ? palette.act : undefined}>{pfx}Model</text>
|
||||
<text fg={isSel ? "cyan" : undefined}>{pfx}Model</text>
|
||||
<text fg="white">{displayName}</text>
|
||||
</box>
|
||||
);
|
||||
@@ -833,7 +833,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={isSel ? palette.act : undefined}>
|
||||
<text fg={isSel ? "cyan" : undefined}>
|
||||
{pfx}
|
||||
{row.label}
|
||||
</text>
|
||||
@@ -866,7 +866,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
: isSel
|
||||
? palette.act
|
||||
? "cyan"
|
||||
: "gray";
|
||||
return (
|
||||
<box
|
||||
@@ -886,7 +886,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
case "mcp-manager":
|
||||
return (
|
||||
<text key={absIdx} fg={isSel ? palette.act : "gray"}>
|
||||
<text key={absIdx} fg={isSel ? "cyan" : "gray"}>
|
||||
{pfx}Manage MCP Servers...
|
||||
</text>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getInputRuleColor,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
getModeInputForeground,
|
||||
getModeInputPlaceholder,
|
||||
} from "../palette";
|
||||
@@ -69,7 +69,7 @@ export function HomeView(props: {
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const accent = getModeAccent(session.uiMode, terminalTheme);
|
||||
const inputRuleColor = getInputRuleColor(terminalBg);
|
||||
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
|
||||
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
|
||||
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
|
||||
const placeholder =
|
||||
@@ -80,7 +80,7 @@ export function HomeView(props: {
|
||||
props.autocomplete?.mode && props.autocomplete.options.length > 0;
|
||||
const contentWidth = Math.min(width, HOME_VIEW_MAX_WIDTH);
|
||||
const hasTypedInput = inputValue.trim().length > 0;
|
||||
const inputStartX = Math.floor((width - contentWidth) / 2) + 2;
|
||||
const inputStartX = Math.floor((width - contentWidth) / 2) + 4;
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, value));
|
||||
const trackedCursorX = hasTypedInput
|
||||
@@ -116,7 +116,7 @@ export function HomeView(props: {
|
||||
<box flexDirection="column" width={contentWidth} flexShrink={0}>
|
||||
<InputBar
|
||||
accent={accent}
|
||||
ruleColor={inputRuleColor}
|
||||
inputBackground={inputBackground}
|
||||
inputForeground={inputForeground}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -10,26 +10,18 @@ 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 {
|
||||
buildFeaturedModelEntries,
|
||||
buildClineModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
@@ -56,9 +48,6 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
@@ -89,7 +78,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
getMainMenuOptions({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -160,19 +150,6 @@ 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(
|
||||
() =>
|
||||
@@ -206,14 +183,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
|
||||
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
|
||||
// Cline featured model picker
|
||||
const recommended = useClineRecommendedModels();
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() =>
|
||||
recommended.data
|
||||
? buildFeaturedModelEntries(activeProviderId, recommended.data)
|
||||
: [],
|
||||
[recommended.data, activeProviderId],
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
@@ -224,43 +198,24 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// The featured picker serves both cline and cline-pass, so pool reasoning
|
||||
// support and display names from both catalogs
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
getLocalProviderModels(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const ids = new Set<string>();
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
for (const m of result.value.models) {
|
||||
getLocalProviderModels("cline")
|
||||
.then(({ models }) => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of models) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
});
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
resolveProviderConfig(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value?.knownModels) {
|
||||
Object.assign(merged, result.value.knownModels);
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged).length > 0) {
|
||||
setClineKnownModels(merged);
|
||||
}
|
||||
});
|
||||
setClineModelReasoningIds(ids);
|
||||
})
|
||||
.catch(() => {});
|
||||
resolveProviderConfig("cline")
|
||||
.then((resolved) => {
|
||||
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
const [thinkingSelected, setThinkingSelected] = useState(
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
);
|
||||
const [thinkingSelected, setThinkingSelected] = useState(0);
|
||||
const [selectedModelName, setSelectedModelName] = useState("");
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
const [selectedThinking, setSelectedThinking] = useState(false);
|
||||
@@ -310,62 +265,6 @@ 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);
|
||||
@@ -389,27 +288,6 @@ 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("");
|
||||
@@ -435,11 +313,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setVerifyUrl: setDeviceVerifyUrl,
|
||||
setStatus: setDeviceStatus,
|
||||
setError: setDeviceError,
|
||||
onComplete: handleAuthComplete,
|
||||
onComplete: transitionToModelPicker,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[providerSettingsManager, handleAuthComplete],
|
||||
[providerSettingsManager, transitionToModelPicker],
|
||||
);
|
||||
|
||||
const startOAuthFlow = useCallback(
|
||||
@@ -461,46 +339,18 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setStatus: setAuthStatus,
|
||||
setAuthUrl,
|
||||
setError: setAuthError,
|
||||
onComplete: handleAuthComplete,
|
||||
onComplete: transitionToModelPicker,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[
|
||||
providerSettingsManager,
|
||||
resetAuth,
|
||||
handleAuthComplete,
|
||||
transitionToModelPicker,
|
||||
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);
|
||||
@@ -664,7 +514,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const entry = modelEntries.find((m) => m.id === modelId);
|
||||
if (entry?.supportsReasoning) {
|
||||
setSelectedModelName(entry.name);
|
||||
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
|
||||
setThinkingSelected(0);
|
||||
setStep("thinking_level");
|
||||
} else {
|
||||
setStep("done");
|
||||
@@ -714,7 +564,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setSelectedModelId(modelId);
|
||||
if (clineModelReasoningIds.has(modelId)) {
|
||||
setSelectedModelName(modelName);
|
||||
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
|
||||
setThinkingSelected(0);
|
||||
setStep("thinking_level");
|
||||
} else {
|
||||
setStep("done");
|
||||
@@ -782,9 +632,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
modelList,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
thinkingSelected,
|
||||
setStep,
|
||||
setMenuSelected,
|
||||
@@ -801,11 +648,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setDeviceError,
|
||||
setDeviceStatus,
|
||||
setClineModelSelected,
|
||||
setClinePassSubscriptionSelected,
|
||||
setThinkingSelected,
|
||||
continueFromClinePassSubscription,
|
||||
refreshClinePassSubscriptionStatus,
|
||||
openClinePassSubscriptionPage,
|
||||
abortOAuth: () => {
|
||||
authAbortRef.current = true;
|
||||
},
|
||||
@@ -827,7 +670,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
return {
|
||||
activeProviderName,
|
||||
activeProviderId,
|
||||
authError,
|
||||
authStatus,
|
||||
authUrl,
|
||||
@@ -840,14 +682,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
clinePassPlanFeatures,
|
||||
clinePassSubscriptionError,
|
||||
clinePassSubscriptionOpenStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionUrl,
|
||||
deviceError,
|
||||
deviceStatus,
|
||||
deviceUserCode,
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
type OnboardingStep,
|
||||
THINKING_LEVELS,
|
||||
@@ -28,9 +26,6 @@ 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>>;
|
||||
@@ -43,11 +38,7 @@ 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;
|
||||
@@ -102,11 +93,6 @@ 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);
|
||||
@@ -149,43 +135,6 @@ 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) =>
|
||||
|
||||
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export type OnboardingStep =
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
| "custom_model_id"
|
||||
@@ -30,10 +29,6 @@ export const THINKING_LEVELS: {
|
||||
{ value: "xhigh", label: "Extra High", desc: "Maximum reasoning" },
|
||||
];
|
||||
|
||||
export const DEFAULT_THINKING_LEVEL_INDEX = THINKING_LEVELS.findIndex(
|
||||
(l) => l.value === "medium",
|
||||
);
|
||||
|
||||
export interface MenuOption {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -41,17 +36,6 @@ 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",
|
||||
@@ -87,25 +71,6 @@ 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;
|
||||
@@ -131,12 +96,6 @@ export interface ModelEntry {
|
||||
supportsReasoning: boolean;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionStatus =
|
||||
| "loading"
|
||||
| "subscribed"
|
||||
| "unsubscribed"
|
||||
| "error";
|
||||
|
||||
export interface ProviderCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -207,6 +166,5 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
// ClinePass uses the featured picker too, with Subscribed/Free sections
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
return providerId === "cline";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -19,18 +17,10 @@ import {
|
||||
TrackedRobot,
|
||||
type useMouseTracker,
|
||||
} from "../../components/tracked-robot";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
import { type MenuOption, THINKING_LEVELS } from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
@@ -39,10 +29,6 @@ function useDefaultFg(): string | undefined {
|
||||
return getDefaultForeground(terminalBg);
|
||||
}
|
||||
|
||||
function getClinePassSubscriptionOptionId(index: number): string {
|
||||
return `cline-pass-subscription-option-${index}`;
|
||||
}
|
||||
|
||||
interface OnboardingFrameProps {
|
||||
children: ReactNode;
|
||||
compact: boolean;
|
||||
@@ -384,7 +370,7 @@ export function OnboardingCodexCliScreen(props: {
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="gray">{props.status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
@@ -482,198 +468,6 @@ 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,7 +6,6 @@ import { useOnboardingController } from "./controller";
|
||||
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
@@ -122,24 +121,6 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "cline_pass_subscription") {
|
||||
return (
|
||||
<OnboardingClinePassSubscriptionScreen
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
currentPlanName={state.clinePassCurrentPlanName}
|
||||
error={state.clinePassSubscriptionError}
|
||||
mouse={mouse}
|
||||
openStatus={state.clinePassSubscriptionOpenStatus}
|
||||
options={state.clinePassSubscriptionOptions}
|
||||
planFeatures={state.clinePassPlanFeatures}
|
||||
selected={state.clinePassSubscriptionSelected}
|
||||
status={state.clinePassSubscriptionStatus}
|
||||
subscriptionUrl={state.clinePassSubscriptionUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "model_picker") {
|
||||
return (
|
||||
<OnboardingModelPickerScreen
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -19,52 +13,14 @@ describe("cline-pass-errors", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const sdkFormatted =
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const formatted = getCliNotSubscribedMessage();
|
||||
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
|
||||
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getCliSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
expect(getClinePassSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription/",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const detail =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
|
||||
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClinePassLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Switch to Cline usage-based billing",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,10 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
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 getCliClinePassLimitMessage(message: string): string {
|
||||
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
|
||||
const lines = [
|
||||
"ClinePass limit reached",
|
||||
detail,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
];
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
export { getClinePassSubscriptionUrl };
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
@@ -73,59 +32,7 @@ export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function getClinePassLimitDetailMessage(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
return extractClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClinePassLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClinePassLimitError" ||
|
||||
isClinePassLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,19 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} from "./events";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
describe("resolveStatusNoticeLabel", () => {
|
||||
it("maps compaction status reasons to stable labels", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "auto-compacting",
|
||||
reason: "auto_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("auto-compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "manual",
|
||||
reason: "manual_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction-budget-adjusted",
|
||||
reason: "compaction_budget_emergency",
|
||||
} as AgentEvent),
|
||||
).toBe("context budget adjusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errorOutput = "";
|
||||
setCurrentOutputMode("text");
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
errorOutput += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
errorOutput += `${args.map(String).join(" ")}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a ⎿ before text that follows a tool block", () => {
|
||||
@@ -205,23 +160,6 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toContain("── aborted (2 iterations) ──");
|
||||
});
|
||||
|
||||
it("formats ClinePass limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("ClinePass limit reached");
|
||||
expect(errorOutput).toContain("Switch to Cline usage-based billing");
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -28,13 +27,8 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
switch (event.reason) {
|
||||
case "auto_compaction":
|
||||
return "auto-compacting";
|
||||
case "manual_compaction":
|
||||
return "compacting";
|
||||
case "compaction_budget_emergency":
|
||||
return "context budget adjusted";
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
@@ -182,7 +176,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
writeErr(event.error.message);
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("zeros cost of free models selected on the cline-pass provider", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// subscription (cline-pass/...) models are not in the free bucket
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
// Free models are also selectable on ClinePass — they ride usage billing at $0
|
||||
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
|
||||
return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function truncate(str: string, maxLen: number): string {
|
||||
return `${oneLine.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function formatStructuredCommand(cmd: unknown): string {
|
||||
function formatStructuredCommand(cmd: unknown): string {
|
||||
if (typeof cmd === "string") {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -42,12 +42,11 @@ export function getPersistedProviderApiKey(
|
||||
* or endpoint config for the provider. Used by the picker to decide whether
|
||||
* to offer "Use existing configuration?" before opening the configure dialog.
|
||||
*
|
||||
* Treats OAuth providers as configured when an access token or a manually
|
||||
* saved API key is present (the /settings escape hatch for when OAuth isn't
|
||||
* working); for everything else, any persisted API key, base URL, or model id
|
||||
* counts. We don't enforce required fields here — the runtime no longer
|
||||
* pre-flights credentials, so a missing key only matters when the API call
|
||||
* actually runs and the provider's own auth error is surfaced.
|
||||
* Treats OAuth providers as configured when an access token is present; for
|
||||
* everything else, any persisted API key, base URL, or model id counts. We
|
||||
* don't enforce required fields here — the runtime no longer pre-flights
|
||||
* credentials, so a missing key only matters when the API call actually
|
||||
* runs and the provider's own auth error is surfaced.
|
||||
*/
|
||||
export function isProviderConfigured(
|
||||
providerId: string,
|
||||
@@ -55,8 +54,7 @@ export function isProviderConfigured(
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProvider(providerId)) {
|
||||
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
|
||||
return Boolean(getPersistedProviderApiKey(providerId, settings));
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
if (settings.baseUrl?.trim()) return true;
|
||||
|
||||
@@ -2,6 +2,7 @@ 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) => {
|
||||
@@ -12,13 +13,20 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("enables ClinePass when listing the SDK provider list", async () => {
|
||||
it("passes the ClinePass feature flag into 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,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user