mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f83420464d | |||
| 8beadfbcd7 | |||
| 3105826a84 | |||
| a41129a5db | |||
| 1ea34be611 | |||
| e72bc3cd14 | |||
| 9c907af826 | |||
| e8d3d82522 | |||
| 7f9d2e96d9 | |||
| d618f8073a | |||
| eb21ba583c | |||
| f29c25395c | |||
| 84c9b587a6 | |||
| 6dca234d8e | |||
| 9217eacbbd | |||
| adbb42a99c | |||
| 50d1578a7e | |||
| 5ef3b81369 | |||
| ec3a57771d | |||
| a695dab23a | |||
| 77af52661c | |||
| 0f4acccd08 | |||
| f8c73cd8cc | |||
| 55a31a0d8a | |||
| 04438c0d54 | |||
| f053ec48e4 | |||
| 0df406723c | |||
| 12703bf407 | |||
| 4a97b46f5f | |||
| 36fc3327ac | |||
| 2b48dc411f | |||
| da6fe718d0 | |||
| 3515333e23 | |||
| bd9ac5872b | |||
| fb15324ad2 | |||
| 7a27c04ffa | |||
| c37b252f65 | |||
| 2872138900 | |||
| dc4620c529 | |||
| 2ac5c85e69 | |||
| ab68fd7f34 | |||
| b4ed8a226e | |||
| cbf40961db | |||
| 2d05ba52da | |||
| 5d3778b5cf | |||
| 8d0eb54a1f | |||
| a3989acc38 | |||
| d199b1bff9 | |||
| d41eed1198 | |||
| 6309971089 | |||
| 2d2c669421 | |||
| c5f146a418 | |||
| 261ee4c313 | |||
| d45b051c04 | |||
| 78c83cdf33 | |||
| 3266121fa1 | |||
| 6467de65a2 | |||
| 65fe885638 | |||
| c3033d6f13 | |||
| cfb1327a1b | |||
| 264af96e1b | |||
| 10cb9bd97a | |||
| 2ee18e7f0c | |||
| 0b65506a2b | |||
| 3502608081 | |||
| ed3107f9ec | |||
| 6bce48aad4 | |||
| 1e1b6af51c | |||
| ee49900232 | |||
| a1d5589d19 | |||
| 721fda2e99 | |||
| 5e78861eb5 | |||
| 29798f59f3 | |||
| 177d0eb07f | |||
| 869a87a220 | |||
| 0cfd0bbe05 | |||
| 08f656532f | |||
| 10dece6677 | |||
| 885a2936b6 | |||
| 90c427740d | |||
| e6028168f2 | |||
| c3f75b3ff0 | |||
| 88ce3e0b11 | |||
| dd719dce86 | |||
| d5db7eb853 | |||
| 11d5ebe8bc | |||
| 6f7cc4907f | |||
| 27e3541569 | |||
| ae9c5b4d9d | |||
| f86ca6b36b | |||
| 0e0b11032e | |||
| a93d850aee | |||
| fe25258b0d | |||
| 53d1567731 | |||
| 9a93008463 | |||
| 678b0ae951 | |||
| 8b6f2cf0b7 |
@@ -0,0 +1,205 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
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 }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
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 }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
@@ -1,14 +1,40 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch. The nightly publish script
|
||||
# generates the extension version from a seconds-resolution timestamp, so parallel
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -17,7 +43,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
if: github.repository == 'cline/cline'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -27,60 +53,79 @@ jobs:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
run: |
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
@@ -90,20 +135,24 @@ jobs:
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
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 }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
@@ -111,12 +160,129 @@ jobs:
|
||||
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 }}
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
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 }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -124,10 +290,11 @@ jobs:
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
VSCODE_TEST_VERSION: 1.101.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -260,6 +260,41 @@ jobs:
|
||||
git push origin "refs/tags/${TAG}"
|
||||
done
|
||||
|
||||
- name: Get Previous SDK Tag
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: prev_tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# The checkout is shallow and tagless, so fetch the release tags explicitly.
|
||||
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
|
||||
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
|
||||
DELIMITER=$(openssl rand -hex 8)
|
||||
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
|
||||
name: "SDK v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -280,3 +315,26 @@ jobs:
|
||||
echo " - sdk/core/v${VERSION}"
|
||||
echo " - sdk/sdk/v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Post release to Slack
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
|
||||
|
||||
@@ -85,3 +85,10 @@ 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,14 +39,6 @@
|
||||
"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,13 +16,9 @@
|
||||
"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, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "sdk/AGENTS.md",
|
||||
|
||||
+22
-17
@@ -36,8 +36,13 @@ 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. 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
|
||||
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.
|
||||
|
||||
**All events should be named using snake_case and so should their properties**
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
@@ -82,7 +87,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` (PR #357):
|
||||
The canonical pattern is in `apps/cli/src/main.ts`:
|
||||
|
||||
```ts
|
||||
if (configDir) setClineDir(configDir);
|
||||
@@ -90,18 +95,18 @@ setHomeDir(homedir());
|
||||
captureCliExtensionActivated(); // <-- after dir overrides
|
||||
```
|
||||
|
||||
## Hub Daemon Metadata Forwarding
|
||||
## Hub Daemon Telemetry
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
```
|
||||
{ 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.
|
||||
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).
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
@@ -120,10 +125,10 @@ canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
+7
-7
@@ -68,7 +68,7 @@
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -89,7 +89,7 @@
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -114,16 +114,16 @@
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
"message": 1
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 model |
|
||||
| Google | Gemini series model |
|
||||
| OpenAI | GPT series models |
|
||||
| Google | Gemini series models |
|
||||
| OpenRouter | 200+ models from any provider |
|
||||
| Vercel AI Gateway | Models through Vercel AI Gateway |
|
||||
| Vercel AI Gateway | Route to many providers through one gateway |
|
||||
| AWS Bedrock | Claude, Llama, and more |
|
||||
| Azure / GCP Vertex | All hosted models |
|
||||
| Cerebras / Groq | Fast inference models |
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.42
|
||||
|
||||
- Fixed Ollama native API routing so context window and timeout settings work again
|
||||
|
||||
## 3.0.41
|
||||
|
||||
- Compaction now shows progress status in the TUI
|
||||
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
|
||||
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
|
||||
- Compaction no longer runs during an active turn
|
||||
- Fixed a crash when the terminal title was updated during TUI teardown
|
||||
- The API key fallback hint is now highlighted for better visibility
|
||||
- Benign git states are no longer reported as workspace initialization errors
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.37",
|
||||
"version": "3.0.42",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -511,6 +511,7 @@ 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 ?? "";
|
||||
@@ -519,6 +520,7 @@ export class AcpAgent implements Agent {
|
||||
providerId,
|
||||
mode: session.currentMode,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
|
||||
return {
|
||||
providerId,
|
||||
@@ -537,7 +539,23 @@ export class AcpAgent implements Agent {
|
||||
enableAgentTeams: false,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot: resolveWorkspaceRoot(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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,22 @@ 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,7 +118,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
// `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/")
|
||||
) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
|
||||
@@ -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.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
|
||||
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.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
});
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
@@ -1013,6 +1014,80 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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"];
|
||||
|
||||
|
||||
+40
-1
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -46,6 +47,7 @@ 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";
|
||||
@@ -926,6 +928,17 @@ 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,
|
||||
@@ -962,6 +975,25 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
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 savedAuth = selectedProviderSettings?.auth;
|
||||
if (savedAuth?.accountId) {
|
||||
identifyTelemetryAccount({
|
||||
id: savedAuth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: savedAuth.organizationId,
|
||||
organizationName: savedAuth.organizationName,
|
||||
memberId: savedAuth.memberId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const persistedApiKey = getPersistedProviderApiKey(
|
||||
provider,
|
||||
selectedProviderSettings,
|
||||
@@ -1029,6 +1061,7 @@ export async function runCli(): Promise<void> {
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -1079,7 +1112,13 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: { name: "cline-cli" },
|
||||
client: {
|
||||
name: "cline-cli",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(400_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -126,10 +126,11 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -137,7 +138,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(360_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -157,7 +158,8 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
@@ -174,7 +176,8 @@ describe("compactInteractiveMessages", () => {
|
||||
messages,
|
||||
});
|
||||
|
||||
const compactedTextLength = result.messages.reduce(
|
||||
const compactedMessages = result.compactionState?.messages ?? [];
|
||||
const compactedTextLength = compactedMessages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(typeof message.content === "string" ? message.content.length : 0),
|
||||
@@ -182,8 +185,9 @@ describe("compactInteractiveMessages", () => {
|
||||
);
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages.length).toBeGreaterThan(1);
|
||||
expect(result.messages.length).toBeLessThan(messages.length);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
@@ -214,8 +218,9 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toHaveLength(messages.length);
|
||||
expect(result.messages[0]?.content).toBe(
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toHaveLength(messages.length);
|
||||
expect(result.compactionState?.messages[0]?.content).toBe(
|
||||
"same count but content should be trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
type ReasoningSettings,
|
||||
type SessionCompactionState,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
@@ -52,13 +54,22 @@ export async function compactInteractiveMessages(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
}): Promise<{ compacted: boolean; messages: Message[] }> {
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
compacted: boolean;
|
||||
canonicalMessages: Message[];
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
modelInfo?.maxInputTokens ??
|
||||
modelInfo?.contextWindow ??
|
||||
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
|
||||
const compactionModelInfo = modelInfo
|
||||
? {
|
||||
...modelInfo,
|
||||
id: modelInfo.id ?? input.config.modelId,
|
||||
}
|
||||
: {
|
||||
id: input.config.modelId,
|
||||
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
|
||||
};
|
||||
const compact = createContextCompactionPrepareTurn(
|
||||
{
|
||||
providerConfig: resolveCompactionProviderConfig(
|
||||
@@ -81,8 +92,11 @@ export async function compactInteractiveMessages(input: {
|
||||
{ mode: "manual" },
|
||||
);
|
||||
if (!compact) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
return { compacted: false, canonicalMessages: 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,
|
||||
@@ -90,21 +104,26 @@ export async function compactInteractiveMessages(input: {
|
||||
iteration: 0,
|
||||
messages: input.messages,
|
||||
apiMessages: input.messages,
|
||||
abortSignal: new AbortController().signal,
|
||||
abortSignal: input.abortSignal ?? new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
model: {
|
||||
id: input.config.modelId,
|
||||
provider: input.config.providerId,
|
||||
info: {
|
||||
...(modelInfo ?? {}),
|
||||
id: modelInfo?.id ?? input.config.modelId,
|
||||
maxInputTokens: maxInputTokens,
|
||||
},
|
||||
info: compactionModelInfo,
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
if (!result?.messages) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
return { compacted: true, messages: result.messages };
|
||||
return {
|
||||
compacted: true,
|
||||
canonicalMessages: input.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
// The tracker moved to @cline/shared so the VSCode extension can share the
|
||||
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
|
||||
// import surface stable.
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
|
||||
@@ -1,81 +1,89 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
type ToolApprovalRequest,
|
||||
type 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 {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
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: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
|
||||
function makeConfig(): Config {
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
enableAgentTeams: true,
|
||||
verbose: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
function createChatCommandState(config = createConfig()): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
@@ -84,6 +92,35 @@ function makeChatCommandState(config: Config): 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",
|
||||
@@ -100,9 +137,9 @@ function makeManager() {
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -114,10 +151,13 @@ 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(),
|
||||
},
|
||||
@@ -133,7 +173,7 @@ function makeTurnResult() {
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
@@ -150,20 +190,22 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
async function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
config?: Config;
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const config = options.config ?? createConfig();
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
chatCommandState: createChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
@@ -172,26 +214,325 @@ function makeRuntime(
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
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);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
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(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
@@ -202,7 +543,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
@@ -212,7 +553,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
@@ -224,7 +565,9 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: { session_id: "session-restarted" },
|
||||
manifest: createManifest("session-restarted"),
|
||||
manifestPath: "/tmp/session-restarted.json",
|
||||
messagesPath: "/tmp/session-restarted.messages.json",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -249,13 +592,13 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
createRuntimeHooksMock.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = makeRuntime(manager, {
|
||||
const runtime = await makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
@@ -307,14 +650,51 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
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(),
|
||||
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(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
@@ -322,16 +702,14 @@ 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(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
@@ -348,8 +726,46 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
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(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
@@ -371,7 +787,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
@@ -399,12 +815,89 @@ 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 = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
@@ -422,7 +915,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
|
||||
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
|
||||
const manager = makeManager();
|
||||
let runtime!: ReturnType<typeof makeRuntime>;
|
||||
let runtime!: Awaited<ReturnType<typeof makeRuntime>>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
@@ -432,7 +925,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = makeRuntime(manager);
|
||||
runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
@@ -450,7 +943,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -46,6 +49,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;
|
||||
};
|
||||
@@ -116,6 +122,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
// 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;
|
||||
|
||||
@@ -205,15 +212,23 @@ 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(),
|
||||
config: {
|
||||
...buildSessionConfig(),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -309,6 +324,25 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
};
|
||||
|
||||
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> => {
|
||||
@@ -343,6 +377,15 @@ export function createInteractiveSessionRuntime(input: {
|
||||
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) {
|
||||
@@ -380,7 +423,15 @@ 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;
|
||||
startupError = undefined;
|
||||
@@ -392,7 +443,12 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
reuseSessionId,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
@@ -411,14 +467,45 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
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;
|
||||
}
|
||||
await restartWithMessages(messages);
|
||||
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 restartEmpty = async (): Promise<void> => {
|
||||
@@ -532,6 +619,10 @@ 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,
|
||||
@@ -539,8 +630,33 @@ export function createInteractiveSessionRuntime(input: {
|
||||
sourceSession: sessionRecord,
|
||||
messages,
|
||||
});
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
await startFreshSession(
|
||||
messages,
|
||||
forkMetadata,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
// Report carried context from what the new session actually accepted:
|
||||
// the host can reject the inherited state (e.g. stale anchor), and the
|
||||
// UI must not claim a carry-over that did not happen.
|
||||
const acceptedState = projectedMessages
|
||||
? await readCompactionState(activeSessionId)
|
||||
: undefined;
|
||||
return {
|
||||
forkedFromSessionId,
|
||||
newSessionId: activeSessionId,
|
||||
carriedWorkingContext: acceptedState
|
||||
? {
|
||||
workingContextMessages: acceptedState.messages.length,
|
||||
canonicalMessages: messages.length,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
@@ -561,9 +677,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
if (!sessionManager) {
|
||||
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) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
@@ -577,12 +701,28 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: activeSessionId,
|
||||
messages,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!result.compacted) {
|
||||
return {
|
||||
messagesBefore,
|
||||
@@ -590,10 +730,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
await restartWithMessages(result.messages);
|
||||
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.");
|
||||
}
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: result.messages.length,
|
||||
messagesAfter: result.canonicalMessages.length,
|
||||
workingContextMessagesAfter: result.compactionState?.messages.length,
|
||||
compacted: true,
|
||||
};
|
||||
};
|
||||
@@ -683,6 +837,9 @@ 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(() => {});
|
||||
@@ -730,6 +887,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
compactCurrentSession,
|
||||
|
||||
@@ -9,23 +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.
|
||||
|
||||
- Read files, search the codebase, and gather context to understand the problem
|
||||
- Ask clarifying questions when requirements are ambiguous
|
||||
- Present your plan as a structured outline with clear steps
|
||||
- Explain tradeoffs between different approaches when they exist
|
||||
- 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.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
explicitSystemPrompt?: string;
|
||||
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
|
||||
mode?: AgentMode;
|
||||
}): 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}`;
|
||||
}
|
||||
// Mode-tag and plan-mode instructions are appended by the shared prompt
|
||||
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
|
||||
// @cline/shared), so only the caller-specific rules are merged here.
|
||||
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
workspaceRoot: input.cwd,
|
||||
|
||||
@@ -43,6 +43,15 @@ 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";
|
||||
|
||||
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
|
||||
.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",
|
||||
@@ -769,6 +802,126 @@ describe("runAgent", () => {
|
||||
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");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
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", () => {
|
||||
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
|
||||
).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
|
||||
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,
|
||||
@@ -687,25 +732,12 @@ export async function runInteractive(
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
onModelChange: () =>
|
||||
applyInteractiveModelChange({
|
||||
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 }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
providerSettingsManager,
|
||||
sessionRuntime,
|
||||
}),
|
||||
onSessionRestart: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.restartEmpty();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
normalizeUserInput,
|
||||
type ToolResultContent,
|
||||
type ToolUseContent,
|
||||
} from "@cline/shared";
|
||||
@@ -681,7 +681,7 @@ function renderContentHTML(
|
||||
toolResultsMap: Map<string, ToolResultContent>,
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
const text = isUser ? normalizeUserInput(content) : content;
|
||||
const text = isUser ? formatDisplayUserInput(content) : content;
|
||||
return renderTextHTML(text);
|
||||
}
|
||||
|
||||
@@ -689,7 +689,7 @@ function renderContentHTML(
|
||||
.map((block) => {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
const text = isUser ? normalizeUserInput(block.text) : block.text;
|
||||
const text = isUser ? formatDisplayUserInput(block.text) : block.text;
|
||||
return renderTextHTML(text);
|
||||
}
|
||||
case "tool_use":
|
||||
|
||||
@@ -151,6 +151,38 @@ export async function createClineAccountService(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active organization so headless runs and the hub daemon can
|
||||
* attach it to telemetry identity. Personal account clears stale org fields.
|
||||
*/
|
||||
function persistClineOrganizationContext(
|
||||
activeOrganization: ClineAccountOrganization | null,
|
||||
userId: string,
|
||||
): void {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: persisted.auth?.accountId ?? userId,
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
@@ -183,6 +215,7 @@ export async function loadClineAccountSnapshot(input: {
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -18,12 +20,13 @@ import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
getUserMessageBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { getSyntaxStyle } from "../utils/syntax-style";
|
||||
import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
parseApplyPatchInput,
|
||||
@@ -131,7 +134,7 @@ function formatToolParams(
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={f.path}>
|
||||
<span key={`${i}:${f.path}`}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
@@ -291,7 +294,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
@@ -299,7 +302,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase ClinePass: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -377,13 +380,13 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
)}
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -419,19 +422,98 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactionDividerRow(props: {
|
||||
entry: Extract<ChatEntry, { kind: "compaction" }>;
|
||||
}) {
|
||||
const { entry } = props;
|
||||
const { width: terminalWidth } = useTerminalDimensions();
|
||||
const inProgress = entry.status === "started";
|
||||
const labelColor = inProgress
|
||||
? "cyan"
|
||||
: entry.status === "failed"
|
||||
? "red"
|
||||
: entry.status === "cancelled" || entry.status === "skipped"
|
||||
? "gray"
|
||||
: "cyan";
|
||||
const label = `✻ ${formatCompactionDividerLabel(entry)} ✻`;
|
||||
// Fill the remaining line with a plain rule instead of a flexGrow bordered
|
||||
// box: a single fixed-content text row keeps the renderer's diffing stable.
|
||||
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
{inProgress ? (
|
||||
<box width={2}>
|
||||
<spinner name="dots" color={labelColor} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg="gray" content="── " />
|
||||
)}
|
||||
<text fg={labelColor} selectable content={label} />
|
||||
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
|
||||
</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, terminalTheme } = props;
|
||||
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const userMsgBg = getModeInputBackground(
|
||||
accent === palette.plan ? "plan" : "act",
|
||||
terminalBg,
|
||||
);
|
||||
const userMsgBg = getUserMessageBackground(terminalBg);
|
||||
|
||||
switch (entry.kind) {
|
||||
case "user":
|
||||
@@ -442,10 +524,9 @@ 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}
|
||||
@@ -461,10 +542,9 @@ 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>}
|
||||
@@ -489,7 +569,7 @@ export function ChatEntryView(props: {
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme)}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
|
||||
streaming={entry.streaming}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
@@ -537,6 +617,15 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -560,12 +649,15 @@ export function ChatEntryView(props: {
|
||||
</box>
|
||||
);
|
||||
|
||||
case "compaction":
|
||||
return <CompactionDividerRow entry={entry} />;
|
||||
|
||||
case "done": {
|
||||
const parts: string[] = [];
|
||||
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(3)}`);
|
||||
if (entry.cost > 0) parts.push(`$${entry.cost.toFixed(2)}`);
|
||||
if (entry.iterations > 0)
|
||||
parts.push(
|
||||
`${entry.iterations} iteration${entry.iterations !== 1 ? "s" : ""}`,
|
||||
|
||||
@@ -96,11 +96,15 @@ 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={accent}
|
||||
accent={getModeAccent(entryMode, terminalTheme)}
|
||||
mode={entryMode === "plan" ? "plan" : "act"}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ export function AccountDialogContent(
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">Cline Account</text>
|
||||
<text fg={palette.act}>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="cyan">Cline Account</text>
|
||||
<text fg={palette.act}>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="cyan">Cline Account</text>
|
||||
<text fg={palette.act}>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="cyan">Change Account</text>
|
||||
<text fg={palette.act}>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="cyan">Cline Account</text>
|
||||
<text fg={palette.act}>Cline Account</text>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
@@ -514,7 +514,7 @@ export function AccountDialogContent(
|
||||
border
|
||||
borderColor="gray"
|
||||
>
|
||||
<text fg="cyan">{userInitial(loaded)}</text>
|
||||
<text fg={palette.act}>{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 : "cyan"}
|
||||
fg={isSelected ? palette.textOnSelection : palette.act}
|
||||
width={shortcutWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ExtDetailContent(
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>{row.name}</strong>
|
||||
</text>
|
||||
<text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @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 }
|
||||
@@ -277,7 +278,7 @@ export function HelpDialogContent(props: ChoiceContext<void>) {
|
||||
}
|
||||
return (
|
||||
<box key={row.id} flexDirection="row" paddingX={1}>
|
||||
<text fg="cyan" width={KEY_WIDTH} flexShrink={0}>
|
||||
<text fg={palette.act} 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="cyan">MCP Servers</text>
|
||||
<text fg={palette.act}>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 ? "cyan" : "gray";
|
||||
let rowColor = isSel ? palette.act : "gray";
|
||||
if (enabled && typeof srv.enabled === "boolean") {
|
||||
rowColor = palette.success;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
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", () => {
|
||||
@@ -16,3 +27,99 @@ describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,7 +37,10 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -371,14 +374,14 @@ function ClinePassBrowserPageContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text>{status}</text>
|
||||
|
||||
<text fg="gray">{pageLabel}:</text>
|
||||
<text fg="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={url}>{url}</a>
|
||||
</text>
|
||||
|
||||
@@ -596,7 +599,7 @@ export function ProviderConfigInputContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -689,7 +692,7 @@ export function CodexCliStatusContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -707,7 +710,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="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
@@ -724,13 +727,27 @@ 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<boolean> & {
|
||||
props: ChoiceContext<OAuthLoginResult> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
allowApiKeyFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerId, providerName } = props;
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -863,13 +880,23 @@ 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";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -884,7 +911,7 @@ export function OAuthLoginContent(
|
||||
<strong>{deviceUserCode}</strong>
|
||||
</text>
|
||||
<text fg="gray">Visit this URL and enter the code above:</text>
|
||||
<text fg="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={deviceVerifyUrl}>{deviceVerifyUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -892,8 +919,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -901,7 +928,7 @@ export function OAuthLoginContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -915,8 +942,83 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
<text fg={escapeHintColor}>
|
||||
<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>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -142,7 +142,7 @@ export function SkillsPickerContent(props: SkillsPickerContentProps) {
|
||||
onMouseDown={() => resolve(SKILLS_MARKETPLACE_ACTION)}
|
||||
height={1}
|
||||
>
|
||||
<text fg={isSelected ? palette.textOnSelection : "cyan"}>
|
||||
<text fg={isSelected ? palette.textOnSelection : palette.act}>
|
||||
{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="cyan" marginTop={1}>
|
||||
<text fg={palette.act} marginTop={1}>
|
||||
<strong>{props.request.toolName}</strong>
|
||||
</text>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export type TextareaHandle = Pick<
|
||||
|
||||
export interface InputBarProps {
|
||||
accent: string;
|
||||
inputBackground: string;
|
||||
ruleColor: string;
|
||||
inputForeground: string;
|
||||
inputPlaceholder: string;
|
||||
placeholder: string;
|
||||
@@ -62,7 +62,7 @@ function readTextPaste(event: PasteEvent): string | null {
|
||||
export function InputBar(props: InputBarProps) {
|
||||
const {
|
||||
accent,
|
||||
inputBackground,
|
||||
ruleColor,
|
||||
inputForeground,
|
||||
inputPlaceholder,
|
||||
placeholder,
|
||||
@@ -197,13 +197,13 @@ export function InputBar(props: InputBarProps) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
backgroundColor={inputBackground}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
border={["top", "bottom"]}
|
||||
borderStyle="single"
|
||||
borderColor={ruleColor}
|
||||
onMouseDown={props.onFocusRequest}
|
||||
>
|
||||
<text fg={accent}>
|
||||
<strong>{">"}</strong>
|
||||
<strong>{"❯"}</strong>
|
||||
</text>
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<textarea
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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,7 +1,6 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
@@ -9,25 +8,28 @@ 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 interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
export {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerBrowse,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return "cyan";
|
||||
return palette.act;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
@@ -39,12 +41,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
@@ -68,20 +71,6 @@ 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;
|
||||
@@ -103,6 +92,7 @@ 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];
|
||||
@@ -112,14 +102,20 @@ export function ClineModelPicker(props: {
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
|
||||
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,7 +3,12 @@ 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 type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
@@ -17,7 +22,7 @@ type ClineModelEntriesState =
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return "cyan";
|
||||
return palette.act;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
@@ -29,12 +34,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
@@ -62,11 +68,13 @@ 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;
|
||||
@@ -76,7 +84,9 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
|
||||
description:
|
||||
entry.tier === "free" ? freeTierDescription : undefined,
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
@@ -156,8 +166,18 @@ export function ClineModelSelectorContent(
|
||||
if (row.kind === "header") {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
marginTop={isFirst ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{row.label}</text>
|
||||
{row.description && (
|
||||
<text fg="gray">
|
||||
<em>{row.description}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +292,7 @@ export function ClineModelSelectorDialogContent(
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="cyan">Choose a model</text>
|
||||
<text fg={palette.act}>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>
|
||||
@@ -282,7 +302,7 @@ export function ClineModelSelectorDialogContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="cyan">Choose a model</text>
|
||||
<text fg={palette.act}>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,7 +329,8 @@ export function ThinkingLevelContent(
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === currentLevel);
|
||||
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
|
||||
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 : "cyan"} flexShrink={0}>
|
||||
<text fg={focused ? palette.selection : palette.act} flexShrink={0}>
|
||||
Provider:
|
||||
</text>
|
||||
<text fg="white">{providerName}</text>
|
||||
|
||||
@@ -14,14 +14,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\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(50, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(100, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
@@ -29,17 +29,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\u2588\u2588",
|
||||
empty: "\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\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588",
|
||||
});
|
||||
expect(createContextBar(1_000_000, 1_000_000)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
@@ -58,22 +58,32 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
).toBe("(12,345) $0.12");
|
||||
});
|
||||
|
||||
it("displays subscription message when the provider is a subscription provider", () => {
|
||||
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", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
).toBe("(12,345 tokens) $0.00 (included with subscription)");
|
||||
).toBe("(12,345)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelDisplayName", () => {
|
||||
it("keeps ClinePass visible when model ids have provider prefixes", () => {
|
||||
it("uses the friendly model name with a ClinePass prefix", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
@@ -82,7 +92,30 @@ describe("resolveModelDisplayName", () => {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
}),
|
||||
).toBe("ClinePass/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", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { HOME_VIEW_MAX_WIDTH } from "../types";
|
||||
export function createContextBar(
|
||||
used: number,
|
||||
total?: number,
|
||||
width = 8,
|
||||
width = 6,
|
||||
): { filled: string; empty: string } {
|
||||
const normalizedWidth = Math.max(0, Math.floor(width));
|
||||
const ratio = total && total > 0 ? Math.min(used / total, 1) : 0;
|
||||
@@ -45,13 +45,13 @@ 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 "$0.00 (included with subscription)";
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!shouldShowCliUsageCost(providerId)) {
|
||||
@@ -66,7 +66,7 @@ export function formatStatusBarUsageText(input: {
|
||||
totalCost: number;
|
||||
providerId: string;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
const costText = formatCostText(input.providerId, input.totalCost);
|
||||
|
||||
if (!costText) {
|
||||
@@ -102,12 +102,12 @@ export function resolveModelDisplayName(config: {
|
||||
}): string {
|
||||
const info = lookupModelInfo(config.modelId, config.knownModels);
|
||||
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
|
||||
const displayName =
|
||||
config.providerId === "cline-pass"
|
||||
? `ClinePass/${modelIdTail}`
|
||||
: (info?.name ?? modelIdTail);
|
||||
let displayName = info?.name ?? modelIdTail;
|
||||
if (config.thinking && config.reasoningEffort) {
|
||||
return `${displayName} (${config.reasoningEffort})`;
|
||||
displayName = `${displayName} (${config.reasoningEffort})`;
|
||||
}
|
||||
if (config.providerId === "cline-pass") {
|
||||
displayName = `ClinePass: ${displayName}`;
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@@ -103,9 +103,16 @@ 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);
|
||||
@@ -132,8 +139,9 @@ export function SessionProvider(props: {
|
||||
);
|
||||
|
||||
const appendEntry = useCallback((entry: ChatEntry) => {
|
||||
const stamped = entry.mode ? entry : { ...entry, mode: uiModeRef.current };
|
||||
setEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
const next = [...prev, stamped];
|
||||
return next.length <= MAX_BUFFERED_LINES
|
||||
? next
|
||||
: next.slice(next.length - MAX_BUFFERED_LINES);
|
||||
@@ -188,8 +196,8 @@ export function SessionProvider(props: {
|
||||
}, []);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setUiMode((m) => (m === "act" ? "plan" : "act"));
|
||||
}, []);
|
||||
setUiMode(uiModeRef.current === "act" ? "plan" : "act");
|
||||
}, [setUiMode]);
|
||||
|
||||
const toggleAutoApprove = useCallback(() => {
|
||||
const next = !autoApproveAllRef.current;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenConfigOptions } from "./use-config-panel";
|
||||
|
||||
export interface LocalSlashCommandActionInput {
|
||||
name: string;
|
||||
isRunning: boolean;
|
||||
openAccount: () => void;
|
||||
openConfig: (options?: OpenConfigOptions) => void;
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
@@ -46,7 +47,12 @@ export function runLocalSlashCommandAction(
|
||||
return true;
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
input.runCompact();
|
||||
// Autocomplete can invoke local commands while a turn is running. Keep
|
||||
// /compact handled, but do not let it take ownership of the active turn's
|
||||
// shared running state.
|
||||
if (!input.isRunning) {
|
||||
input.runCompact();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (normalized === "fork") {
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type AccountDialogAction,
|
||||
AccountDialogContent,
|
||||
} from "../components/dialogs/account-dialog";
|
||||
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
|
||||
import {
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export function useAccountDialog(opts: {
|
||||
@@ -60,14 +63,14 @@ export function useAccountDialog(opts: {
|
||||
return;
|
||||
}
|
||||
if (action === "login") {
|
||||
const saved = await dialog.choice<boolean>({
|
||||
const saved = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
if (saved === true) {
|
||||
await onAccountChange?.();
|
||||
await openAccountDialog();
|
||||
return;
|
||||
|
||||
@@ -6,13 +6,14 @@ import type {
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveStatusNoticeLabel } from "../../utils/events";
|
||||
import { resolveNonCompactionStatusLabel } from "../../utils/events";
|
||||
import {
|
||||
formatToolInput,
|
||||
formatToolOutput,
|
||||
truncate,
|
||||
} from "../../utils/helpers";
|
||||
import type { ChatEntry, InlineStream, TuiProps } from "../types";
|
||||
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
|
||||
|
||||
interface AgentEventDeps {
|
||||
appendEntry: (entry: ChatEntry) => void;
|
||||
@@ -32,6 +33,7 @@ interface AgentEventDeps {
|
||||
}
|
||||
|
||||
export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const openCompactionEntryRef = useRef(false);
|
||||
const {
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
@@ -45,6 +47,47 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
verbose,
|
||||
} = deps;
|
||||
|
||||
// Compaction dividers that arrived while an assistant message was still
|
||||
// streaming. Appending them immediately would split the message in two, so
|
||||
// they are held until the active content block closes (or the turn ends).
|
||||
const pendingCompactionEntriesRef = useRef<
|
||||
Extract<ChatEntry, { kind: "compaction" }>[]
|
||||
>([]);
|
||||
|
||||
const flushPendingCompactionEntries = useCallback(() => {
|
||||
const pending = pendingCompactionEntriesRef.current;
|
||||
if (pending.length === 0) return;
|
||||
pendingCompactionEntriesRef.current = [];
|
||||
for (const entry of pending) {
|
||||
if (entry.status !== "started" && openCompactionEntryRef.current) {
|
||||
updateEntry((current) =>
|
||||
current.kind === "compaction" && current.status === "started"
|
||||
? { ...current, ...entry }
|
||||
: current,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry(entry);
|
||||
if (entry.status === "started") {
|
||||
openCompactionEntryRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [appendEntry, updateEntry]);
|
||||
|
||||
const finalizeDanglingCompactionEntry = useCallback(
|
||||
(status: "failed" | "cancelled") => {
|
||||
if (!openCompactionEntryRef.current) return;
|
||||
openCompactionEntryRef.current = false;
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status }
|
||||
: entry,
|
||||
);
|
||||
},
|
||||
[updateEntry],
|
||||
);
|
||||
|
||||
const closeToolEntry = useCallback(
|
||||
(event: AgentEvent & { type: "content_end" }) => {
|
||||
const error = event.error ?? undefined;
|
||||
@@ -84,9 +127,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(true);
|
||||
setIsStreaming(true);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "iteration_end":
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "content_start": {
|
||||
setIsStreaming(false);
|
||||
@@ -165,11 +210,15 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("cancelled");
|
||||
break;
|
||||
case "error":
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("failed");
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
@@ -181,8 +230,40 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
break;
|
||||
case "notice":
|
||||
if (event.displayRole === "status") {
|
||||
closeInlineStream();
|
||||
const label = resolveStatusNoticeLabel(event);
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (!compaction) {
|
||||
closeInlineStream();
|
||||
}
|
||||
if (compaction) {
|
||||
if (activeInlineStreamRef.current) {
|
||||
// An assistant message is still streaming; appending now
|
||||
// would split it around the divider. Hold the divider (final
|
||||
// state until the content block closes, then reconcile it
|
||||
// with the same open divider atomically.
|
||||
pendingCompactionEntriesRef.current.push({
|
||||
kind: "compaction",
|
||||
...compaction,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (compaction.status === "started") {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
openCompactionEntryRef.current = true;
|
||||
} else if (openCompactionEntryRef.current) {
|
||||
// Finalize the in-progress divider in place, wherever it
|
||||
// sits in the transcript.
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, ...compaction }
|
||||
: entry,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
}
|
||||
break;
|
||||
}
|
||||
const label = resolveNonCompactionStatusLabel(event);
|
||||
if (label) {
|
||||
appendEntry({ kind: "status", text: label });
|
||||
}
|
||||
@@ -200,6 +281,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
[
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
updateEntry,
|
||||
closeInlineStream,
|
||||
activeInlineStreamRef,
|
||||
setIsRunning,
|
||||
@@ -208,6 +290,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
closeToolEntry,
|
||||
finalizeDanglingCompactionEntry,
|
||||
flushPendingCompactionEntries,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ function makeActions(
|
||||
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
|
||||
): Omit<LocalSlashCommandActionInput, "name"> {
|
||||
return {
|
||||
isRunning: false,
|
||||
openAccount: vi.fn(),
|
||||
openConfig: vi.fn(),
|
||||
openMcpManager: vi.fn(async () => false),
|
||||
@@ -58,6 +59,32 @@ describe("runLocalSlashCommandAction", () => {
|
||||
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
|
||||
});
|
||||
|
||||
it("does not start compaction while a turn is running", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ isRunning: true, runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts compaction while the session is idle", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("waits for clear to reset the runtime session", async () => {
|
||||
let resolveClear: (() => void) | undefined;
|
||||
const clearConversation = vi.fn(
|
||||
@@ -161,7 +188,7 @@ describe("formatCompactionStatus", () => {
|
||||
messagesAfter: 300,
|
||||
compacted: true,
|
||||
}),
|
||||
).toBe("Compacted context; message count stayed at 300.");
|
||||
).toBe("Compacted context; message count stayed at 300 messages.");
|
||||
});
|
||||
|
||||
it("reports empty sessions separately", () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import type { AppView, TuiProps } from "../types";
|
||||
import { formatCompactionStatus } from "../utils/compaction-status";
|
||||
import { hydrateSessionMessages } from "../utils/hydrate-messages";
|
||||
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
|
||||
import { HistoryDialogContent } from "../views/history-view";
|
||||
@@ -75,9 +74,10 @@ export function useLocalCommandActions(input: {
|
||||
});
|
||||
} else {
|
||||
session.clearEntries();
|
||||
for (const entry of entries) {
|
||||
session.appendEntry(entry);
|
||||
}
|
||||
// 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);
|
||||
if (typeof result.currentContextSize === "number") {
|
||||
session.setLastTotalTokens(result.currentContextSize);
|
||||
}
|
||||
@@ -115,21 +115,42 @@ export function useLocalCommandActions(input: {
|
||||
}, [dialog, refocusTextarea, termHeight]);
|
||||
|
||||
const runCompact = useCallback(async () => {
|
||||
session.setIsRunning(true);
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: "Compacting context...",
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
});
|
||||
try {
|
||||
const result = await onCompact();
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "status",
|
||||
text: formatCompactionStatus(result),
|
||||
}));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? {
|
||||
...entry,
|
||||
status: result.compacted ? "completed" : "skipped",
|
||||
messagesBefore: result.messagesBefore,
|
||||
messagesAfter:
|
||||
result.workingContextMessagesAfter ?? result.messagesAfter,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
} catch (error) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
const cancelled =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || /abort/i.test(error.message));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status: cancelled ? "cancelled" : "failed" }
|
||||
: entry,
|
||||
);
|
||||
if (!cancelled) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
session.setIsRunning(false);
|
||||
}
|
||||
}, [onCompact, session]);
|
||||
|
||||
@@ -158,6 +179,15 @@ export function useLocalCommandActions(input: {
|
||||
kind: "status",
|
||||
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
|
||||
}));
|
||||
if (result.carriedWorkingContext) {
|
||||
session.appendEntry({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: result.carriedWorkingContext.canonicalMessages,
|
||||
messagesAfter: result.carriedWorkingContext.workingContextMessages,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "error",
|
||||
@@ -180,6 +210,7 @@ export function useLocalCommandActions(input: {
|
||||
}
|
||||
return runLocalSlashCommandAction({
|
||||
name: resolved.name,
|
||||
isRunning: session.isRunning,
|
||||
invocation,
|
||||
openAccount,
|
||||
openConfig,
|
||||
@@ -208,6 +239,7 @@ export function useLocalCommandActions(input: {
|
||||
openSkills,
|
||||
runCompact,
|
||||
runFork,
|
||||
session.isRunning,
|
||||
slashCommandRegistry,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
UseExistingOrReconfigureContent,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
@@ -79,6 +82,51 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
|
||||
* using the provider's stored API key and headers, mirroring the extension's
|
||||
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
|
||||
* to manual model-id entry.
|
||||
*/
|
||||
async function fetchOpenAiCompatibleModelIds(
|
||||
providerId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const config = manager.getProviderConfig(providerId, {
|
||||
includeKnownModels: false,
|
||||
});
|
||||
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
|
||||
if (!baseUrl || !URL.canParse(baseUrl)) return [];
|
||||
|
||||
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
|
||||
const apiKey = config?.apiKey?.trim();
|
||||
if (
|
||||
apiKey &&
|
||||
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
|
||||
) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const ids = list
|
||||
.map((model) => {
|
||||
const id = (model as { id?: unknown } | null)?.id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return [...new Set(ids)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function providerToExistingProviderOptions(input: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -131,6 +179,23 @@ 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;
|
||||
@@ -165,17 +230,22 @@ async function runProviderChange(
|
||||
if (needsAuth) {
|
||||
let saved: boolean | undefined;
|
||||
if (isOAuthProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
const loginResult = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<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 },
|
||||
@@ -275,12 +345,28 @@ export function useModelSelector(opts: {
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
let providerDisplayName = config.providerId;
|
||||
let endpointModelOptions: ModelOption[] = [];
|
||||
|
||||
const refreshProviderContext = async () => {
|
||||
modelOptions = buildModelOptions(
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
providerDisplayName = await getProviderDisplayName(config.providerId);
|
||||
// Free-text providers (openai-compatible) can still suggest model
|
||||
// ids when their endpoint answers /models; otherwise they keep the
|
||||
// manual input.
|
||||
endpointModelOptions = usesModelIdInput(config.providerId)
|
||||
? buildModelOptions(
|
||||
Object.fromEntries(
|
||||
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
|
||||
(id) => [id, { id, name: id }],
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (endpointModelOptions.length > 0) {
|
||||
modelOptions = endpointModelOptions;
|
||||
}
|
||||
};
|
||||
|
||||
if (!options?.startWithProviderChange) {
|
||||
@@ -316,7 +402,10 @@ export function useModelSelector(opts: {
|
||||
let pickingModel = true;
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
if (
|
||||
usesModelIdInput(config.providerId) &&
|
||||
endpointModelOptions.length === 0
|
||||
) {
|
||||
const modelId = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -341,7 +430,13 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.providerId === "cline") {
|
||||
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;
|
||||
const clineResult = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -351,7 +446,10 @@ export function useModelSelector(opts: {
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
buildFeaturedModelEntries(
|
||||
featuredProviderId,
|
||||
await fetchClineRecommendedModels(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type TerminalTitleRenderer,
|
||||
useTerminalTitle,
|
||||
} from "./use-terminal-title";
|
||||
|
||||
const reactMock = vi.hoisted(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
return {
|
||||
cleanups,
|
||||
// Run effect bodies now, but retain their cleanups so each test can move
|
||||
// the renderer across the native destruction boundary before unmount.
|
||||
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) {
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: reactMock.useEffect,
|
||||
}));
|
||||
|
||||
function createTitleRenderer() {
|
||||
let destroyed = false;
|
||||
const setTerminalTitle = vi.fn(() => {
|
||||
if (destroyed) {
|
||||
throw new Error("setTerminalTitle called after renderer destruction");
|
||||
}
|
||||
});
|
||||
const renderer: TerminalTitleRenderer = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
setTerminalTitle,
|
||||
};
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
renderer,
|
||||
setTerminalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactMock.cleanups.length = 0;
|
||||
reactMock.useEffect.mockClear();
|
||||
});
|
||||
|
||||
describe("useTerminalTitle", () => {
|
||||
it("sets and resets the title while the renderer is active", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
|
||||
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
|
||||
});
|
||||
|
||||
it("does not set the title when its effect runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
titleRenderer.destroy();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset the title when cleanup runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
|
||||
titleRenderer.destroy();
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TerminalTitleRenderer {
|
||||
readonly isDestroyed: boolean;
|
||||
setTerminalTitle(title: string): void;
|
||||
}
|
||||
|
||||
export function useTerminalTitle(
|
||||
renderer: TerminalTitleRenderer,
|
||||
terminalTitle: string,
|
||||
): void {
|
||||
// setTerminalTitle writes into memory owned by the native renderer, so it
|
||||
// must never run after destroy. React can flush passive effects after the
|
||||
// renderer's memory has been freed.
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
|
||||
defaultBackground: null,
|
||||
defaultForeground: null,
|
||||
})),
|
||||
isDestroyed: false,
|
||||
on: vi.fn(),
|
||||
setTerminalTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
const rootMock = vi.hoisted(() => ({
|
||||
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
destroyHandlers.length = 0;
|
||||
rendererMock.isDestroyed = false;
|
||||
rendererMock.destroy.mockReset();
|
||||
rendererMock.setTerminalTitle.mockReset();
|
||||
rendererMock.on.mockReset();
|
||||
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === "destroy") {
|
||||
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the terminal title before destroying the renderer", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
const titleCallOrder =
|
||||
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
|
||||
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
|
||||
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
|
||||
});
|
||||
|
||||
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
// Simulate OpenTUI's own signal handler destroying the renderer in the
|
||||
// same dispatch (e.g. an idle SIGTERM fires both our handler and
|
||||
// OpenTUI's exitHandler before microtasks drain).
|
||||
rendererMock.isDestroyed = true;
|
||||
for (const handler of destroyHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function renderOpenTui(
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
// Reset the title while the native renderer is still alive; the
|
||||
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
|
||||
// Re-check here: OpenTUI's own signal handlers can destroy the
|
||||
// renderer between destroy() queuing this microtask and it running
|
||||
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -23,15 +23,15 @@ describe("getTerminalTheme", () => {
|
||||
});
|
||||
|
||||
describe("theme-aware palette helpers", () => {
|
||||
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 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("uses darker accents on light terminals", () => {
|
||||
expect(getModeAccent("act", "light")).toBe("#0969da");
|
||||
expect(getModeAccent("plan", "light")).toBe("#9a6700");
|
||||
expect(getModeAccent("act", "light")).toBe("#0f72cb");
|
||||
expect(getModeAccent("plan", "light")).toBe("#867100");
|
||||
expect(getSuccessColor("light")).toBe("#116329");
|
||||
});
|
||||
});
|
||||
|
||||
+50
-17
@@ -1,9 +1,9 @@
|
||||
export const palette = {
|
||||
act: "cyan",
|
||||
plan: "yellow",
|
||||
selection: "cyan",
|
||||
act: "#79b8ff",
|
||||
plan: "#ffea7f",
|
||||
selection: "#79b8ff",
|
||||
error: "red",
|
||||
success: "brightGreen",
|
||||
success: "#99e89b",
|
||||
muted: "gray",
|
||||
textOnSelection: "black",
|
||||
} as const;
|
||||
@@ -16,9 +16,11 @@ 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: "#0969da",
|
||||
plan: "#9a6700",
|
||||
act: "#0f72cb",
|
||||
plan: "#867100",
|
||||
success: "#116329",
|
||||
},
|
||||
} as const;
|
||||
@@ -29,7 +31,7 @@ export const diffPalettes = {
|
||||
removedBg: "#4d1a1a",
|
||||
addedLineNumberBg: "#1a4d1a",
|
||||
removedLineNumberBg: "#4d1a1a",
|
||||
addedSignColor: "#22c55e",
|
||||
addedSignColor: "#99e89b",
|
||||
removedSignColor: "#ef4444",
|
||||
lineNumberFg: "#888888",
|
||||
},
|
||||
@@ -75,8 +77,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/cyan):
|
||||
// -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/blue):
|
||||
// -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):
|
||||
@@ -131,22 +133,53 @@ export function getDefaultForeground(
|
||||
return isLightTheme(terminalBg) ? "#1a1a1a" : undefined;
|
||||
}
|
||||
|
||||
export function getModeInputBackground(
|
||||
mode: string,
|
||||
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 = BASE_LIFT / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
|
||||
const warm = mode === "plan";
|
||||
const lift = baseLift / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
|
||||
return oklabToHex(
|
||||
base.L + (light ? -lift : lift),
|
||||
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + CHROMA_NUDGE,
|
||||
base.a + nudgeA,
|
||||
base.b + nudgeB,
|
||||
);
|
||||
}
|
||||
|
||||
export function getModeInputBackground(
|
||||
mode: string,
|
||||
terminalBg: string | null,
|
||||
): string {
|
||||
const warm = mode === "plan";
|
||||
return liftedFromTerminalBg(
|
||||
terminalBg,
|
||||
BASE_LIFT,
|
||||
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
|
||||
warm ? CHROMA_NUDGE : -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,
|
||||
@@ -157,7 +190,7 @@ export function getModeInputForeground(
|
||||
return oklabToHex(
|
||||
base.L,
|
||||
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + CHROMA_NUDGE,
|
||||
base.b + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,7 +204,7 @@ export function getModeInputPlaceholder(
|
||||
return oklabToHex(
|
||||
base.L,
|
||||
base.a + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
|
||||
base.b + CHROMA_NUDGE * 2,
|
||||
base.b + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
|
||||
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import type { AppView, TuiProps } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
@@ -401,9 +402,10 @@ function App(props: TuiProps) {
|
||||
if (lastEntry && lastEntry.kind === "user_submitted") {
|
||||
entries.pop();
|
||||
}
|
||||
for (const entry of entries) {
|
||||
session.appendEntry(entry);
|
||||
}
|
||||
// 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);
|
||||
session.setHasSubmitted(entries.length > 0);
|
||||
setAppView(entries.length > 0 ? "chat" : "home");
|
||||
populateInputRef.current(picked.fullText);
|
||||
@@ -471,15 +473,7 @@ function App(props: TuiProps) {
|
||||
};
|
||||
}, [renderer, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
renderer.setTerminalTitle("");
|
||||
};
|
||||
}, [renderer]);
|
||||
useTerminalTitle(renderer, terminalTitle);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -25,7 +25,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 }
|
||||
@@ -44,6 +44,15 @@ export type ChatEntry =
|
||||
}
|
||||
| { kind: "error"; text: string }
|
||||
| { kind: "status"; text: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
compactionMode: "auto" | "manual" | "inherited";
|
||||
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
messagesBefore?: number;
|
||||
messagesAfter?: number;
|
||||
}
|
||||
| { kind: "team"; text: string }
|
||||
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
|
||||
| {
|
||||
@@ -52,7 +61,17 @@ 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: {
|
||||
@@ -80,6 +99,7 @@ export interface ResumedSessionResult {
|
||||
export interface InteractiveCompactionResult {
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
@@ -175,7 +195,15 @@ export interface TuiProps {
|
||||
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
|
||||
onCompact: () => Promise<InteractiveCompactionResult>;
|
||||
onFork: () => Promise<
|
||||
{ forkedFromSessionId: string; newSessionId: string } | undefined
|
||||
| {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
formatTokenCount,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "./compaction-status";
|
||||
|
||||
describe("parseCompactionNoticeMetadata", () => {
|
||||
it("extracts a divider entry from a completed auto-compaction notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
reason: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toEqual({
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a streaming divider entry from a started notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "started",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "started" });
|
||||
});
|
||||
|
||||
it("maps manual compaction notices to manual mode", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "manual_compaction",
|
||||
phase: "completed",
|
||||
})?.compactionMode,
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("maps a benign no-result terminal notice to skipped", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "skipped",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "skipped" });
|
||||
});
|
||||
|
||||
it("ignores non-compaction metadata", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
|
||||
).toBeUndefined();
|
||||
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-numeric counters instead of rendering garbage", () => {
|
||||
const parsed = parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: "25000",
|
||||
tokensAfter: Number.NaN,
|
||||
});
|
||||
expect(parsed?.tokensBefore).toBeUndefined();
|
||||
expect(parsed?.tokensAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats counts into compact units", () => {
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
expect(formatTokenCount(6_300)).toBe("6.3k");
|
||||
expect(formatTokenCount(25_000)).toBe("25k");
|
||||
expect(formatTokenCount(1_200_000)).toBe("1.2M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactionDividerLabel", () => {
|
||||
it("includes token and message deltas when present", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
|
||||
});
|
||||
|
||||
it("labels in-progress compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Auto compacting messages");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Compacting messages");
|
||||
});
|
||||
|
||||
it("labels failed and cancelled compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "failed",
|
||||
}),
|
||||
).toBe("Compaction failed");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "cancelled",
|
||||
}),
|
||||
).toBe("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("labels skipped compaction without calling it cancelled", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "skipped",
|
||||
}),
|
||||
).toBe("Compaction skipped");
|
||||
});
|
||||
|
||||
it("labels inherited working context from forks and restarts", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: 60,
|
||||
messagesAfter: 15,
|
||||
}),
|
||||
).toBe("Compacted working context carried over · 60 → 15 messages");
|
||||
});
|
||||
|
||||
it("labels manual compaction and omits missing counters", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "completed",
|
||||
}),
|
||||
).toBe("Context compacted (manual)");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,105 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
import type { ChatEntry, InteractiveCompactionResult } from "../types";
|
||||
|
||||
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a compaction divider entry from a status notice's metadata.
|
||||
* "started" notices produce a streaming (in-progress) divider; "completed"
|
||||
* notices produce the final divider with counters. Returns undefined for
|
||||
* non-compaction notices.
|
||||
*/
|
||||
export function parseCompactionNoticeMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Omit<CompactionDividerEntry, "kind"> | undefined {
|
||||
if (
|
||||
!metadata ||
|
||||
(metadata.phase !== "started" &&
|
||||
metadata.phase !== "completed" &&
|
||||
metadata.phase !== "skipped")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = metadata.kind ?? metadata.reason;
|
||||
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
|
||||
return undefined;
|
||||
}
|
||||
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
|
||||
if (metadata.phase === "started") {
|
||||
return { compactionMode, status: "started" };
|
||||
}
|
||||
if (metadata.phase === "skipped") {
|
||||
return { compactionMode, status: "skipped" };
|
||||
}
|
||||
return {
|
||||
compactionMode,
|
||||
status: "completed",
|
||||
tokensBefore: asFiniteNumber(metadata.tokensBefore),
|
||||
tokensAfter: asFiniteNumber(metadata.tokensAfter),
|
||||
messagesBefore: asFiniteNumber(metadata.messagesBefore),
|
||||
messagesAfter: asFiniteNumber(metadata.messagesAfter),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1_000) {
|
||||
return `${count}`;
|
||||
}
|
||||
if (count < 1_000_000) {
|
||||
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
}
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
|
||||
export function formatCompactionDividerLabel(
|
||||
entry: CompactionDividerEntry,
|
||||
): string {
|
||||
if (entry.status === "started") {
|
||||
return entry.compactionMode === "manual"
|
||||
? "Compacting messages"
|
||||
: "Auto compacting messages";
|
||||
}
|
||||
if (entry.status === "failed") {
|
||||
return "Compaction failed";
|
||||
}
|
||||
if (entry.status === "cancelled") {
|
||||
return "Compaction cancelled";
|
||||
}
|
||||
if (entry.status === "skipped") {
|
||||
return "Compaction skipped";
|
||||
}
|
||||
const parts: string[] = [
|
||||
entry.compactionMode === "manual"
|
||||
? "Context compacted (manual)"
|
||||
: entry.compactionMode === "inherited"
|
||||
? "Compacted working context carried over"
|
||||
: "Context compacted",
|
||||
];
|
||||
if (
|
||||
typeof entry.tokensBefore === "number" &&
|
||||
typeof entry.tokensAfter === "number"
|
||||
) {
|
||||
parts.push(
|
||||
`${formatTokenCount(entry.tokensBefore)} → ${formatTokenCount(entry.tokensAfter)} tokens`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof entry.messagesBefore === "number" &&
|
||||
typeof entry.messagesAfter === "number"
|
||||
) {
|
||||
parts.push(`${entry.messagesBefore} → ${entry.messagesAfter} messages`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
@@ -9,8 +110,11 @@ export function formatCompactionStatus(
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("hydrateSessionMessages", () => {
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "lets do it" },
|
||||
{ kind: "user_submitted", text: "lets do it", mode: "plan" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,116 @@ describe("hydrateSessionMessages", () => {
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "assistant_text", text: "On it.", streaming: false },
|
||||
{ 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,4 +1,9 @@
|
||||
import { formatDisplayUserInput, type Message } from "@cline/shared";
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
parseUserInputMode,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
@@ -37,6 +42,12 @@ 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);
|
||||
@@ -46,15 +57,17 @@ 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 });
|
||||
entries.push({ kind: "user_submitted", text, mode });
|
||||
}
|
||||
} else {
|
||||
entries.push({
|
||||
kind: "assistant_text",
|
||||
text: msg.content,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -71,6 +84,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
kind: "assistant_text",
|
||||
text: block.text,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -81,6 +95,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
kind: "reasoning",
|
||||
text: block.thinking,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -96,8 +111,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -123,9 +144,10 @@ 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 });
|
||||
entries.push({ kind: "user_submitted", text, mode });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,33 @@ 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,10 +1,12 @@
|
||||
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
import { type TerminalTheme, themePalette } from "../palette";
|
||||
|
||||
const instances: Record<TerminalTheme, SyntaxStyle | null> = {
|
||||
dark: null,
|
||||
light: null,
|
||||
};
|
||||
// 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>();
|
||||
|
||||
interface SyntaxColors {
|
||||
keyword: string;
|
||||
@@ -22,34 +24,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: "#c678dd",
|
||||
operator: "#56b6c2",
|
||||
type: "#e5c07b",
|
||||
functionName: "#61afef",
|
||||
variable: "#e06c75",
|
||||
string: "#98c379",
|
||||
number: "#d19a66",
|
||||
keyword: "#d7a0e3",
|
||||
operator: "#9bbbdd",
|
||||
type: "#dfca7d",
|
||||
functionName: themePalette.dark.act,
|
||||
variable: "#ee939b",
|
||||
string: "#99e89b",
|
||||
number: "#f0ad7f",
|
||||
comment: "#5c6370",
|
||||
punctuation: "#abb2bf",
|
||||
property: "#e06c75",
|
||||
constant: "#d19a66",
|
||||
tag: "#e06c75",
|
||||
attribute: "#d19a66",
|
||||
escape: "#56b6c2",
|
||||
markdownCode: "#98c379",
|
||||
markdownHeading: "#56b6c2",
|
||||
property: "#ee939b",
|
||||
constant: "#f0ad7f",
|
||||
tag: "#ee939b",
|
||||
attribute: "#f0ad7f",
|
||||
escape: "#9bbbdd",
|
||||
markdownCode: "#99e89b",
|
||||
markdownMuted: "#808080",
|
||||
markdownLink: "#56b6c2",
|
||||
markdownItalic: "#e5c07b",
|
||||
markdownItalic: "#dfca7d",
|
||||
},
|
||||
light: {
|
||||
keyword: "#cf222e",
|
||||
@@ -67,9 +69,7 @@ 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 underline(hex: string): StyleDefinition {
|
||||
return { fg: color(hex), underline: true };
|
||||
}
|
||||
|
||||
function buildSyntaxStyle(theme: TerminalTheme): SyntaxStyle {
|
||||
function buildSyntaxStyle(
|
||||
theme: TerminalTheme,
|
||||
mode: SyntaxAccentMode,
|
||||
): SyntaxStyle {
|
||||
const colors = syntaxColors[theme];
|
||||
const markdownHeading = color(colors.markdownHeading);
|
||||
const accent = color(themePalette[theme][mode]);
|
||||
const markdownHeading = accent;
|
||||
const markdownCode = color(colors.markdownCode);
|
||||
const markdownMuted = color(colors.markdownMuted);
|
||||
const markdownLink = color(colors.markdownLink);
|
||||
const markdownLink = accent;
|
||||
|
||||
return SyntaxStyle.fromStyles({
|
||||
...(colors.markdownDefault ? { default: fg(colors.markdownDefault) } : {}),
|
||||
@@ -145,10 +145,19 @@ function buildSyntaxStyle(theme: TerminalTheme): SyntaxStyle {
|
||||
"markup.link.url": { fg: markdownLink, underline: true },
|
||||
label: { fg: markdownLink },
|
||||
conceal: { fg: markdownMuted },
|
||||
"string.special.url": underline(colors.markdownLink),
|
||||
"string.special.url": { fg: markdownLink, underline: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function getSyntaxStyle(theme: TerminalTheme = "dark"): SyntaxStyle {
|
||||
return (instances[theme] ??= buildSyntaxStyle(theme));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useTerminalTheme,
|
||||
} from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getInputRuleColor,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
getModeInputForeground,
|
||||
@@ -76,6 +77,7 @@ 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 =
|
||||
@@ -123,10 +125,10 @@ export function ChatView(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<box marginBottom={1}>
|
||||
<box>
|
||||
<InputBar
|
||||
accent={accent}
|
||||
inputBackground={inputBackground}
|
||||
ruleColor={inputRuleColor}
|
||||
inputForeground={inputForeground}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -721,7 +721,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg="cyan">
|
||||
<text fg={palette.act}>
|
||||
<strong>Settings</strong>
|
||||
</text>
|
||||
|
||||
@@ -793,7 +793,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={isSel ? "cyan" : undefined}>{pfx}Provider</text>
|
||||
<text fg={isSel ? palette.act : 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 ? "cyan" : undefined}>{pfx}Model</text>
|
||||
<text fg={isSel ? palette.act : 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 ? "cyan" : undefined}>
|
||||
<text fg={isSel ? palette.act : undefined}>
|
||||
{pfx}
|
||||
{row.label}
|
||||
</text>
|
||||
@@ -866,7 +866,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
: isSel
|
||||
? "cyan"
|
||||
? palette.act
|
||||
: "gray";
|
||||
return (
|
||||
<box
|
||||
@@ -886,7 +886,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
case "mcp-manager":
|
||||
return (
|
||||
<text key={absIdx} fg={isSel ? "cyan" : "gray"}>
|
||||
<text key={absIdx} fg={isSel ? palette.act : "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 inputBackground = getModeInputBackground(session.uiMode, terminalBg);
|
||||
const inputRuleColor = getInputRuleColor(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) + 4;
|
||||
const inputStartX = Math.floor((width - contentWidth) / 2) + 2;
|
||||
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}
|
||||
inputBackground={inputBackground}
|
||||
ruleColor={inputRuleColor}
|
||||
inputForeground={inputForeground}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
loadIndividualSubscriptionPlansFromProviderSettings,
|
||||
} from "../../cline-account";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildFeaturedModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
@@ -58,6 +58,7 @@ import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
@@ -205,11 +206,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
|
||||
// Cline featured model picker
|
||||
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
|
||||
const recommended = useClineRecommendedModels();
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
() =>
|
||||
recommended.data
|
||||
? buildFeaturedModelEntries(activeProviderId, recommended.data)
|
||||
: [],
|
||||
[recommended.data, activeProviderId],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
@@ -220,24 +224,43 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLocalProviderModels("cline")
|
||||
.then(({ models }) => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of models) {
|
||||
// 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) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
})
|
||||
.catch(() => {});
|
||||
resolveProviderConfig("cline")
|
||||
.then((resolved) => {
|
||||
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
const [thinkingSelected, setThinkingSelected] = useState(0);
|
||||
const [thinkingSelected, setThinkingSelected] = useState(
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
);
|
||||
const [selectedModelName, setSelectedModelName] = useState("");
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
const [selectedThinking, setSelectedThinking] = useState(false);
|
||||
@@ -641,7 +664,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const entry = modelEntries.find((m) => m.id === modelId);
|
||||
if (entry?.supportsReasoning) {
|
||||
setSelectedModelName(entry.name);
|
||||
setThinkingSelected(0);
|
||||
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
|
||||
setStep("thinking_level");
|
||||
} else {
|
||||
setStep("done");
|
||||
@@ -691,7 +714,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setSelectedModelId(modelId);
|
||||
if (clineModelReasoningIds.has(modelId)) {
|
||||
setSelectedModelName(modelName);
|
||||
setThinkingSelected(0);
|
||||
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
|
||||
setStep("thinking_level");
|
||||
} else {
|
||||
setStep("done");
|
||||
|
||||
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ 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;
|
||||
@@ -203,5 +207,6 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
// ClinePass uses the featured picker too, with Subscribed/Free sections
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
@@ -384,7 +384,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="cyan" selectable>
|
||||
<text fg={palette.act} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ChatCommandState = {
|
||||
export type ForkSessionResult = {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
/**
|
||||
* Present when the source session had valid compaction state that was
|
||||
* re-anchored onto the forked session, so the UI can surface why the
|
||||
* next request is smaller than the canonical history.
|
||||
*/
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MuteCommandInput = {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
|
||||
).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,10 +1,13 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
@@ -24,6 +27,18 @@ 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[] {
|
||||
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
const config = createConfig({ enabled: true, maxInputTokens: 123 });
|
||||
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
|
||||
|
||||
applyCliCompactionMode(config, "basic");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("basic");
|
||||
|
||||
applyCliCompactionMode(config, "off");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: false,
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("off");
|
||||
});
|
||||
|
||||
@@ -1,19 +1,64 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} 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", () => {
|
||||
@@ -160,6 +205,23 @@ 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,4 +1,9 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "../tui/utils/compaction-status";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -27,8 +32,31 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (compaction) {
|
||||
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
|
||||
}
|
||||
return resolveNonCompactionStatusLabel(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a status notice already known not to be a compaction notice.
|
||||
* Callers that have parsed the compaction metadata themselves use this to
|
||||
* avoid re-parsing.
|
||||
*/
|
||||
export function resolveNonCompactionStatusLabel(
|
||||
event: AgentEvent,
|
||||
): string | undefined {
|
||||
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";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
@@ -176,7 +204,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(event.error.message);
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -53,6 +53,37 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
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",
|
||||
|
||||
@@ -73,7 +73,9 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -42,11 +42,12 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
export function isProviderConfigured(
|
||||
providerId: string,
|
||||
@@ -54,7 +55,8 @@ export function isProviderConfigured(
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
|
||||
return Boolean(getPersistedProviderApiKey(providerId, settings));
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
if (settings.baseUrl?.trim()) return true;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
@@ -103,7 +104,9 @@ export async function handleDesktopCommand(
|
||||
): Promise<unknown> {
|
||||
if (command === "list_provider_catalog") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
@@ -165,6 +168,11 @@ export async function handleDesktopCommand(
|
||||
providerId,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(providerSettingsManager, providerId, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
@@ -99,7 +100,9 @@ export async function sendProviderCatalog(
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
ctx.send(peer, {
|
||||
type: "provider_catalog",
|
||||
providers: payload.providers,
|
||||
@@ -138,6 +141,11 @@ export async function runProviderOAuthLogin(
|
||||
normalized,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== normalized) {
|
||||
markLocalProviderEnabled(providerSettingsManager, normalized, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
providerId: normalized,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import type {
|
||||
WebviewActionSessionSummary,
|
||||
WebviewChatMessage,
|
||||
@@ -213,10 +214,16 @@ export function mapHistoryToWebviewMessages(
|
||||
const currentToolBlockIndexes = new Map<string, number>();
|
||||
let reasoningRedacted = false;
|
||||
|
||||
// Persisted user text arrives raw, including runtime-generated
|
||||
// <user_input>/<mode_notice> wrappers -- format at this display
|
||||
// boundary so the webview never renders them.
|
||||
const displayText = (text: string): string =>
|
||||
role === "user" ? formatDisplayUserInput(text) : text;
|
||||
|
||||
const contentParts = historyContentParts(record.content);
|
||||
if (contentParts.length === 0) {
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
pushTextBlock(blocks, textParts, messageKey, 0, text);
|
||||
pushTextBlock(blocks, textParts, messageKey, 0, displayText(text));
|
||||
}
|
||||
|
||||
for (const [partIndex, part] of contentParts.entries()) {
|
||||
@@ -227,7 +234,7 @@ export function mapHistoryToWebviewMessages(
|
||||
textParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.text) ?? asString(part.content) ?? "",
|
||||
displayText(asString(part.text) ?? asString(part.content) ?? ""),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
// react-markdown/streamdown pass the hast `Element` here, whose
|
||||
// `properties` is a broad `Record`. Keep this assignable from that type
|
||||
// (rather than a narrow `{ metastring?: string }`) so the component stays
|
||||
// compatible with `Components` regardless of how strict the resolved
|
||||
// hast/streamdown types are; the metastring value is validated at read time.
|
||||
node?: {
|
||||
properties?: {
|
||||
metastring?: string;
|
||||
};
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -67,7 +70,8 @@ const MarkdownCode = ({
|
||||
);
|
||||
}
|
||||
|
||||
const meta = node?.properties?.metastring;
|
||||
const metaValue = node?.properties?.metastring;
|
||||
const meta = typeof metaValue === "string" ? metaValue : undefined;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
@@ -258,8 +258,8 @@ export function SettingsView({
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -386,7 +386,7 @@ export function SettingsView({
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
usesOAuth(selectedProvider)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -13,8 +13,60 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
|
||||
package. Other Cline web surfaces can take only its tokens or opt into the
|
||||
Tailwind adapter and shared base styles without depending on the desktop
|
||||
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
|
||||
desktop integration notes.
|
||||
|
||||
## Shareable Desktop Packages
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
- macOS: `bun run package:desktop:mac`
|
||||
- Windows: `bun run package:desktop:windows`
|
||||
- Linux: `bun run package:desktop:linux`
|
||||
|
||||
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
|
||||
|
||||
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
|
||||
|
||||
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
|
||||
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
|
||||
|
||||
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
|
||||
|
||||
### macOS signing & notarization, step by step
|
||||
|
||||
One-time keychain setup:
|
||||
|
||||
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
|
||||
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
|
||||
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
|
||||
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
|
||||
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
|
||||
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
|
||||
|
||||
Per-build:
|
||||
|
||||
```bash
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
|
||||
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
|
||||
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
|
||||
export APPLE_API_ISSUER="<issuer UUID>"
|
||||
bun run package:desktop:mac
|
||||
```
|
||||
|
||||
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
|
||||
|
||||
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
Startup flow:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
@@ -10,6 +10,11 @@
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
@@ -19,7 +24,9 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
@@ -48,6 +55,9 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -58,7 +68,6 @@
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"marked": "^17.0.3",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -66,11 +75,11 @@
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
@@ -80,6 +89,7 @@
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "1.3.3",
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type DesktopPlatform = "mac" | "windows" | "linux";
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
);
|
||||
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
|
||||
|
||||
process.chdir(APP_ROOT);
|
||||
|
||||
const validateArgs = (): void => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`missing value for ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
|
||||
throw new Error(
|
||||
suggestion
|
||||
? `unknown option ${arg}. Did you mean ${suggestion}?`
|
||||
: `unknown option ${arg}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argument ${arg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getArgValue = (name: string): string | undefined => {
|
||||
const prefix = `${name}=`;
|
||||
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) {
|
||||
return inline.slice(prefix.length);
|
||||
}
|
||||
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index >= 0) {
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasArg = (name: string): boolean => process.argv.includes(name);
|
||||
|
||||
const hostPlatform = (): DesktopPlatform => {
|
||||
if (process.platform === "darwin") {
|
||||
return "mac";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "windows";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return "linux";
|
||||
}
|
||||
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
|
||||
};
|
||||
|
||||
const resolveRequestedPlatform = (): DesktopPlatform => {
|
||||
const platform =
|
||||
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
|
||||
if (platform === "current") {
|
||||
return hostPlatform();
|
||||
}
|
||||
if (platform === "mac" || platform === "windows" || platform === "linux") {
|
||||
return platform;
|
||||
}
|
||||
throw new Error(
|
||||
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeName = (value: string): string =>
|
||||
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
|
||||
|
||||
const packageVersion = async (): Promise<string> => {
|
||||
const packageJson = await Bun.file(
|
||||
path.join(APP_ROOT, "package.json"),
|
||||
).json();
|
||||
return String(packageJson.version ?? "0.0.0");
|
||||
};
|
||||
|
||||
const macDistributionCredentialsConfigured = (): boolean => {
|
||||
const hasCertificate = Boolean(
|
||||
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
|
||||
);
|
||||
const hasAppleIdNotarization = Boolean(
|
||||
process.env.APPLE_ID &&
|
||||
process.env.APPLE_PASSWORD &&
|
||||
process.env.APPLE_TEAM_ID,
|
||||
);
|
||||
const hasApiKeyNotarization = Boolean(
|
||||
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
|
||||
process.env.APPLE_API_KEY_ID &&
|
||||
process.env.APPLE_API_ISSUER,
|
||||
);
|
||||
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
|
||||
};
|
||||
|
||||
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
|
||||
const host = hostPlatform();
|
||||
if (platform !== host) {
|
||||
throw new Error(
|
||||
[
|
||||
`cannot build ${platform} desktop bundles from ${host}.`,
|
||||
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
|
||||
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
|
||||
if (hostPlatform() !== "mac") {
|
||||
return;
|
||||
}
|
||||
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
|
||||
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
|
||||
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
|
||||
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
|
||||
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
|
||||
const walkFiles = (root: string): string[] => {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = path.join(root, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
paths.push(...walkFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
paths.push(fullPath);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const copyArtifact = (source: string, outputName: string): string => {
|
||||
const destination = path.join(PACKAGE_ROOT, outputName);
|
||||
rmSync(destination, { force: true, recursive: true });
|
||||
cpSync(source, destination, { recursive: true });
|
||||
return destination;
|
||||
};
|
||||
|
||||
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --force --deep --sign - ${appPath}`;
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const verifySignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`spctl --assess --type execute --verbose ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const collectMacArtifacts = async (
|
||||
version: string,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
|
||||
if (!existsSync(appPath)) {
|
||||
throw new Error(`macOS app bundle was not created at ${appPath}`);
|
||||
}
|
||||
|
||||
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
|
||||
console.warn(
|
||||
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
|
||||
);
|
||||
await signUnsignedMacApp(appPath);
|
||||
} else {
|
||||
await verifySignedMacApp(appPath);
|
||||
}
|
||||
|
||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
const suffix =
|
||||
allowUnsignedMac && !macDistributionCredentialsConfigured()
|
||||
? "-local-unsigned"
|
||||
: "";
|
||||
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
|
||||
const zipPath = path.join(PACKAGE_ROOT, zipName);
|
||||
rmSync(zipPath, { force: true });
|
||||
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
|
||||
|
||||
const artifacts = [zipPath];
|
||||
if (!suffix) {
|
||||
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
|
||||
(file) => file.endsWith(".dmg"),
|
||||
)) {
|
||||
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
};
|
||||
|
||||
const collectWindowsArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectLinuxArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.endsWith(".AppImage") ||
|
||||
file.endsWith(".deb") ||
|
||||
file.endsWith(".rpm"),
|
||||
)
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectArtifacts = async (
|
||||
platform: DesktopPlatform,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const version = await packageVersion();
|
||||
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
|
||||
mkdirSync(PACKAGE_ROOT, { recursive: true });
|
||||
|
||||
if (platform === "mac") {
|
||||
return collectMacArtifacts(version, allowUnsignedMac);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsArtifacts();
|
||||
}
|
||||
return collectLinuxArtifacts();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
validateArgs();
|
||||
|
||||
const platform = resolveRequestedPlatform();
|
||||
const allowUnsignedMac =
|
||||
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
|
||||
const skipBuild = hasArg("--skip-build");
|
||||
|
||||
assertCanBuildPlatform(platform);
|
||||
if (platform === "mac") {
|
||||
assertMacDistributionReady(allowUnsignedMac);
|
||||
}
|
||||
|
||||
if (!skipBuild) {
|
||||
await $`bun run build:binary`;
|
||||
}
|
||||
|
||||
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(
|
||||
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Packaged ${platform} desktop artifacts:`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
handleChatSessionCommand,
|
||||
prewarmWorkspaceMetadata,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinking")).toBe(false);
|
||||
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears reasoning settings when thinking is explicitly disabled", () => {
|
||||
expect(
|
||||
buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
reasoningEffort: null,
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates explicit reasoning settings without clearing omitted settings", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUpdateSessionConnection", () => {
|
||||
it("skips the redundant connection update on the first send", () => {
|
||||
const config = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
|
||||
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the connection when the selected reasoning level changes", () => {
|
||||
const current = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldUpdateSessionConnection(current, {
|
||||
...current,
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
|
||||
function createContext(options?: {
|
||||
attachedViaHub?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const updateSessionConnection = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
text: "done",
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
}));
|
||||
const sessionId = "session-connection-test";
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: options?.config ?? baseConfig,
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
attachedViaHub: options?.attachedViaHub ?? false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessionManager: { send, updateSessionConnection },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, send, sessionId, updateSessionConnection };
|
||||
}
|
||||
|
||||
it("skips an identical update for a locally-created session", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).not.toHaveBeenCalled();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates a changed connection before sending", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext({
|
||||
config: { ...baseConfig, reasoningEffort: "low" },
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
send.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes hub-attached sessions even when the cached config matches", async () => {
|
||||
const { ctx, sessionId, updateSessionConnection } = createContext({
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace metadata prewarming", () => {
|
||||
it("reuses one in-flight scan and consumes it only once", async () => {
|
||||
let resolveFirst: ((value: string) => void) | undefined;
|
||||
const firstResult = new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockImplementationOnce(async () => await firstResult)
|
||||
.mockResolvedValueOnce("fresh metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-reuse";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load);
|
||||
const consumed = consumeWorkspaceMetadata(cwd, load);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
resolveFirst?.("prewarmed metadata");
|
||||
|
||||
await expect(consumed).resolves.toBe("prewarmed metadata");
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
|
||||
"fresh metadata",
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts failed scans so the next session can retry", async () => {
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("git unavailable"))
|
||||
.mockResolvedValueOnce("recovered metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-retry";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load);
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
|
||||
"git unavailable",
|
||||
);
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
|
||||
"recovered metadata",
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps different workspaces in separate single-flight entries", () => {
|
||||
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
|
||||
|
||||
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
|
||||
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes a prewarm that is older than the startup window", async () => {
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockResolvedValueOnce("startup metadata")
|
||||
.mockResolvedValueOnce("current metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-expired";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load, () => 0);
|
||||
await expect(
|
||||
consumeWorkspaceMetadata(
|
||||
cwd,
|
||||
load,
|
||||
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
|
||||
),
|
||||
).resolves.toBe("current metadata");
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
@@ -20,6 +22,73 @@ import type {
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
|
||||
type WorkspaceMetadataCacheEntry = {
|
||||
createdAt: number;
|
||||
promise: Promise<string>;
|
||||
};
|
||||
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
|
||||
const workspaceMetadataPromises = new Map<
|
||||
string,
|
||||
WorkspaceMetadataCacheEntry
|
||||
>();
|
||||
|
||||
function getWorkspaceMetadataPromise(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader,
|
||||
now: () => number,
|
||||
): { key: string; promise: Promise<string> } {
|
||||
const key = resolve(cwd);
|
||||
const existing = workspaceMetadataPromises.get(key);
|
||||
const createdAt = now();
|
||||
if (
|
||||
existing &&
|
||||
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
|
||||
) {
|
||||
return { key, promise: existing.promise };
|
||||
}
|
||||
const promise = load(key);
|
||||
workspaceMetadataPromises.set(key, { createdAt, promise });
|
||||
void promise.catch(() => {
|
||||
if (workspaceMetadataPromises.get(key)?.promise === promise) {
|
||||
workspaceMetadataPromises.delete(key);
|
||||
}
|
||||
});
|
||||
return { key, promise };
|
||||
}
|
||||
|
||||
export function prewarmWorkspaceMetadata(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
|
||||
now: () => number = Date.now,
|
||||
): void {
|
||||
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
|
||||
}
|
||||
|
||||
export async function consumeWorkspaceMetadata(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
|
||||
now: () => number = Date.now,
|
||||
): Promise<string> {
|
||||
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (workspaceMetadataPromises.get(key)?.promise === promise) {
|
||||
workspaceMetadataPromises.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshWorkspaceMetadata(cwd: string): void {
|
||||
workspaceMetadataPromises.delete(resolve(cwd));
|
||||
prewarmWorkspaceMetadata(cwd);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -103,7 +172,40 @@ function isoTimestampToMs(
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readReasoningEffort(
|
||||
value: unknown,
|
||||
): "low" | "medium" | "high" | "xhigh" | undefined {
|
||||
if (
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return {
|
||||
sessionId: config.sessionId ?? config.session_id,
|
||||
providerId: config.provider ?? config.providerId ?? "",
|
||||
@@ -125,6 +227,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
config.enableAgentTeams ??
|
||||
config.enable_teams ??
|
||||
false,
|
||||
...(thinking !== undefined ? { thinking } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
teamName: config.teamName ?? config.team_name,
|
||||
missionLogIntervalSteps:
|
||||
config.missionStepInterval ?? config.missionLogIntervalSteps,
|
||||
@@ -136,6 +241,58 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
|
||||
// into typed fields; the thinking/reasoning transition rules live in the
|
||||
// shared @cline/core builder.
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
const rawApiKey =
|
||||
typeof config.apiKey === "string"
|
||||
? config.apiKey.trim()
|
||||
: typeof config.api_key === "string"
|
||||
? config.api_key.trim()
|
||||
: undefined;
|
||||
const baseUrl =
|
||||
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return buildConnectionUpdate({
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(rawApiKey ? { apiKey: rawApiKey } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(config.headers && typeof config.headers === "object"
|
||||
? { headers: config.headers as Record<string, string> }
|
||||
: {}),
|
||||
...(config.providerConfig && typeof config.providerConfig === "object"
|
||||
? {
|
||||
providerConfig:
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"],
|
||||
}
|
||||
: {}),
|
||||
...(typeof config.thinking === "boolean"
|
||||
? { thinking: config.thinking }
|
||||
: {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUpdateSessionConnection(
|
||||
currentConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): boolean {
|
||||
return !isDeepStrictEqual(
|
||||
buildSessionConnectionUpdate(currentConfig),
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -149,7 +306,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
: config.mode === "plan"
|
||||
? "plan"
|
||||
: "act";
|
||||
const metadata = await buildWorkspaceMetadata(cwd);
|
||||
const metadata = await consumeWorkspaceMetadata(cwd);
|
||||
const inlineRules =
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
? config.rules
|
||||
@@ -363,6 +520,19 @@ async function handleSend(
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, request.config)
|
||||
) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
}
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective delivery mode.
|
||||
// When the session is busy and no explicit delivery was requested, queue it
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -35,13 +42,26 @@ import {
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
findArtifactUnderDir,
|
||||
readSessionManifest,
|
||||
@@ -945,7 +965,7 @@ export async function handleCommand(
|
||||
if (command === "list_provider_catalog") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
await ensureCustomProvidersLoaded(manager);
|
||||
return await listLocalProviders(manager);
|
||||
return await listLocalProviders(manager, { isClinePassEnabled: true });
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -1025,12 +1045,45 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Global settings ────────────────────────────────────────────────
|
||||
if (command === "get_global_settings") {
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_telemetry_opt_out") {
|
||||
if (typeof args?.telemetry_opt_out !== "boolean") {
|
||||
throw new Error("telemetry_opt_out must be a boolean");
|
||||
}
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return await startConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
if (command === "stop_connector_channel") {
|
||||
return await stopConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
|
||||
// ── MCP server management ─────────────────────────────────────────
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
@@ -1116,10 +1169,13 @@ export async function handleCommand(
|
||||
|
||||
// ── Git operations ─────────────────────────────────────────────────
|
||||
if (command === "get_git_branch") {
|
||||
const branches = listGitBranches(
|
||||
ctx,
|
||||
typeof args?.cwd === "string" ? args.cwd : undefined,
|
||||
);
|
||||
const cwd =
|
||||
typeof args?.cwd === "string" && args.cwd.trim()
|
||||
? args.cwd.trim()
|
||||
: ctx.workspaceRoot;
|
||||
const branches = listGitBranches(ctx, cwd);
|
||||
const { prewarmWorkspaceMetadata } = await import("./chat-session");
|
||||
prewarmWorkspaceMetadata(cwd);
|
||||
return { branch: branches.current };
|
||||
}
|
||||
if (command === "list_git_branches") {
|
||||
@@ -1132,11 +1188,14 @@ export async function handleCommand(
|
||||
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
|
||||
const branch = String(args?.branch ?? "").trim();
|
||||
if (!branch) throw new Error("branch is required");
|
||||
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
|
||||
execFileSync("git", ["checkout", branch], {
|
||||
cwd: cwd?.trim() || ctx.workspaceRoot,
|
||||
cwd: targetCwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const { refreshWorkspaceMetadata } = await import("./chat-session");
|
||||
refreshWorkspaceMetadata(targetCwd);
|
||||
return { branch };
|
||||
}
|
||||
|
||||
@@ -1156,6 +1215,26 @@ export async function handleCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(ctx.workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) {
|
||||
@@ -1185,6 +1264,15 @@ export async function handleCommand(
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "validate_workspace_directory") {
|
||||
const workspacePath = String(args?.path ?? "").trim();
|
||||
if (!workspacePath) return { valid: false };
|
||||
try {
|
||||
return { valid: statSync(workspacePath).isDirectory() };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
if (command === "pick_workspace_directory") {
|
||||
return pickWorkspaceDirectory();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type WebviewConnectorChannelsResponse = {
|
||||
available: WebviewConnectorChannel[];
|
||||
active: ReturnType<typeof listActiveConnectors>;
|
||||
};
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath =
|
||||
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
type: platform.type,
|
||||
hint: platform.hint,
|
||||
fields: platform.fields.map((field) => ({
|
||||
flag: field.flag,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
prompt: platform.security.prompt,
|
||||
fields: platform.security.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
requiredMessage: field.requiredMessage,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { available, active: listActiveConnectors() };
|
||||
}
|
||||
|
||||
async function runCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
): Promise<{
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve(exitCode ?? 0));
|
||||
});
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function waitForConnectorState(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`connector did not reach expected state within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(platform.id)) {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
() =>
|
||||
!listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user