mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
656e170d11 | ||
|
|
8d9f63b06e | ||
|
|
dc44e00fe8 | ||
|
|
677bf62a09 | ||
|
|
faa96c5d1e | ||
|
|
9a5e1751b2 | ||
|
|
131e25e1a1 | ||
|
|
a7ff007af9 | ||
|
|
ef27f45080 | ||
|
|
e3c6d51072 | ||
|
|
48bac25548 | ||
|
|
37f5f104f3 | ||
|
|
3577b52404 | ||
|
|
1843bc8ed0 | ||
|
|
fead00ec57 | ||
|
|
238107d21c | ||
|
|
2063a661bd | ||
|
|
ec02d5862e | ||
|
|
4d86b283dc | ||
|
|
adf029e8bd | ||
|
|
c85a0be86a | ||
|
|
8452084842 | ||
|
|
a41129a5db | ||
|
|
1ea34be611 | ||
|
|
e72bc3cd14 | ||
|
|
9c907af826 | ||
|
|
e8d3d82522 | ||
|
|
7f9d2e96d9 | ||
|
|
d618f8073a | ||
|
|
eb21ba583c | ||
|
|
f29c25395c | ||
|
|
84c9b587a6 | ||
|
|
6dca234d8e | ||
|
|
9217eacbbd |
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
|
||||
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -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,17 +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
|
||||
@@ -20,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
|
||||
@@ -30,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
|
||||
@@ -93,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
|
||||
@@ -114,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: |
|
||||
@@ -127,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}"
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
|
||||
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
|
||||
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
|
||||
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
|
||||
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
|
||||
|
||||
`--key` takes precedence over environment variables.
|
||||
|
||||
## Certificate trust
|
||||
|
||||
The CLI automatically trusts your operating system's certificate store, so it
|
||||
works behind corporate TLS-inspecting proxies and with self-signed/internal
|
||||
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
|
||||
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
|
||||
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
|
||||
it changes and is safe to delete (it is rebuilt on the next run).
|
||||
|
||||
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
|
||||
that bundle alongside the system store rather than replacing it. Run with
|
||||
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
|
||||
was written.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Auto-discovery of OS trust anchors for the Cline CLI.
|
||||
//
|
||||
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
|
||||
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
|
||||
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
|
||||
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
|
||||
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
|
||||
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
|
||||
//
|
||||
// Dependency-free CommonJS with injectable modules so it is unit-testable and
|
||||
// ships verbatim in the published wrapper package.
|
||||
|
||||
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
|
||||
const CERT_BLOCK =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
/**
|
||||
* Returns only the complete certificate blocks from PEM text, or null when
|
||||
* there are none. User files may also hold private keys (combined cert+key
|
||||
* PEMs) or other sections, which must never be copied into the managed
|
||||
* bundle. Files that contain nothing but certificates pass through verbatim
|
||||
* so unchanged bundles keep hash-skipping the rewrite.
|
||||
*/
|
||||
function sanitizePem(text) {
|
||||
const blocks = text.match(CERT_BLOCK) ?? [];
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = text.replace(CERT_BLOCK, "");
|
||||
if (/^\s*$/.test(rest)) {
|
||||
return text;
|
||||
}
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
|
||||
* tls.getCACertificates("system") requires Node >= 22.
|
||||
*/
|
||||
function harvestSystemCerts(tlsModule) {
|
||||
try {
|
||||
const tls = tlsModule || require("node:tls");
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return [];
|
||||
}
|
||||
const certs = tls.getCACertificates("system");
|
||||
if (!Array.isArray(certs)) {
|
||||
return [];
|
||||
}
|
||||
return certs.filter(
|
||||
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file's certificate blocks as PEM text, or null when missing,
|
||||
* unreadable, or holding no complete certificate block.
|
||||
*/
|
||||
function readUserBundle(fsModule, userPath) {
|
||||
if (!userPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = fsModule || require("node:fs");
|
||||
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
// Binary DER would not have loaded in the runtime either; require PEM.
|
||||
return sanitizePem(fs.readFileSync(userPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
|
||||
* value as a single file, but some users set an OS-path-delimited list; the
|
||||
* whole value is tried as one file first, then split.
|
||||
* The managed bundle is excluded so reading it back never re-appends its certs.
|
||||
*/
|
||||
function readUserCerts(fsModule, pathModule, value, managedPath) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const fs = fsModule || require("node:fs");
|
||||
const path = pathModule || require("node:path");
|
||||
const candidates = [];
|
||||
const whole = readUserBundle(fs, value);
|
||||
if (whole) {
|
||||
candidates.push({ filePath: value, pem: whole });
|
||||
} else if (value.includes(path.delimiter)) {
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const pem = readUserBundle(fs, trimmed);
|
||||
if (pem) {
|
||||
candidates.push({ filePath: trimmed, pem });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pems = [];
|
||||
for (const candidate of candidates) {
|
||||
const isManaged =
|
||||
managedPath &&
|
||||
path.resolve(candidate.filePath) === path.resolve(managedPath);
|
||||
if (!isManaged) {
|
||||
pems.push(candidate.pem);
|
||||
}
|
||||
}
|
||||
return pems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates the user PEMs (if any) and the system certificates into one
|
||||
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
|
||||
* markers cannot fuse into one invalid line.
|
||||
*/
|
||||
function buildBundle({ systemCerts, userPems }) {
|
||||
const parts = [...(userPems ?? []), ...systemCerts];
|
||||
return parts
|
||||
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Counts individual PEM certificates across the given bundle strings. */
|
||||
function countCerts(pems) {
|
||||
let count = 0;
|
||||
for (const pem of pems) {
|
||||
count += pem.split(PEM_MARKER).length - 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readFileIfExists(fs, filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClineDir(env, os, path) {
|
||||
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the api-unavailable warning should print. Stamped per Node version
|
||||
* in the cline dir so the nudge shows once rather than on every command; a
|
||||
* version change (upgrade that still falls short, or downgrade) re-arms it.
|
||||
* When the stamp cannot be read or written, warn — bookkeeping failures must
|
||||
* never suppress a real diagnostic.
|
||||
*/
|
||||
function shouldWarnApiUnavailable(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const version = deps.nodeVersion || process.versions.node;
|
||||
const dir = resolveClineDir(env, os, path);
|
||||
const stamp = path.join(dir, `.ca-api-warned-${version}`);
|
||||
try {
|
||||
if (fs.existsSync(stamp)) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(stamp, "", { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically writes [content] to [target]; returns true on success. */
|
||||
function writeBundle(fs, dir, target, content) {
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Owner read/write: the bundle holds public CA material, not secrets,
|
||||
// but there is no reason to make it world-writable.
|
||||
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
// Windows can reject rename over a file a concurrent child holds open.
|
||||
fs.rmSync(target, { force: true });
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Ignore: best-effort cleanup.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
|
||||
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
|
||||
* in place. Returns an outcome the caller can log; `action` is one of
|
||||
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
|
||||
* "no-system-certs" | "api-unavailable".
|
||||
*/
|
||||
function configureNodeExtraCaCerts(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const tls = deps.tls || require("node:tls");
|
||||
|
||||
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
|
||||
// harvest cannot run at all, which the caller should surface to the user.
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return {
|
||||
action: "api-unavailable",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const systemCerts = harvestSystemCerts(tls);
|
||||
if (systemCerts.length === 0) {
|
||||
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
|
||||
// and let the runtime fall back to its bundled CAs.
|
||||
return {
|
||||
action: "no-system-certs",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const managedDir = resolveClineDir(env, os, path);
|
||||
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
|
||||
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
|
||||
const userPems = readUserCerts(fs, path, userValue, managedPath);
|
||||
const bundle = buildBundle({ systemCerts, userPems });
|
||||
const base = {
|
||||
path: managedPath,
|
||||
systemCertCount: systemCerts.length,
|
||||
userCertCount: countCerts(userPems),
|
||||
};
|
||||
|
||||
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
|
||||
// and the concurrent-rename race in the steady state.
|
||||
if (readFileIfExists(fs, managedPath) === bundle) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "unchanged" };
|
||||
}
|
||||
|
||||
if (writeBundle(fs, managedDir, managedPath, bundle)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "written" };
|
||||
}
|
||||
|
||||
// Write failed: fall back to a previously-written bundle if one exists.
|
||||
if (readFileIfExists(fs, managedPath)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "write-failed-reused" };
|
||||
}
|
||||
return { ...base, path: null, action: "write-failed" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
harvestSystemCerts,
|
||||
sanitizePem,
|
||||
readUserBundle,
|
||||
readUserCerts,
|
||||
buildBundle,
|
||||
countCerts,
|
||||
configureNodeExtraCaCerts,
|
||||
shouldWarnApiUnavailable,
|
||||
};
|
||||
@@ -23,6 +23,48 @@ const childEnv = {
|
||||
CLINE_WRAPPER_PATH: scriptPath,
|
||||
};
|
||||
|
||||
// Auto-discover OS trust anchors and pass them to the Bun child via
|
||||
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
|
||||
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
|
||||
// Node, which can read the full store here.
|
||||
try {
|
||||
const caCerts = require("./ca-certs.cjs");
|
||||
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
|
||||
const debug =
|
||||
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
|
||||
// Not debug-gated: on old Nodes the harvest silently doing nothing is
|
||||
// indistinguishable from a broken corporate proxy. Stamped per Node
|
||||
// version so the nudge shows once, not on every command.
|
||||
if (
|
||||
outcome &&
|
||||
outcome.action === "api-unavailable" &&
|
||||
!childEnv.NODE_EXTRA_CA_CERTS &&
|
||||
caCerts.shouldWarnApiUnavailable(childEnv)
|
||||
) {
|
||||
console.warn(
|
||||
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
|
||||
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
|
||||
);
|
||||
}
|
||||
if (debug && outcome) {
|
||||
if (outcome.action === "no-system-certs") {
|
||||
console.warn(
|
||||
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else if (outcome.action === "write-failed") {
|
||||
console.warn(
|
||||
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: fall back to the runtime's default trust on any failure.
|
||||
}
|
||||
|
||||
function run(target) {
|
||||
const result = childProcess.spawnSync(target, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.40",
|
||||
"version": "3.0.44",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// The helper ships as CommonJS in the published wrapper package, so it is
|
||||
// loaded via require rather than an ESM import.
|
||||
const caCerts = require("../../bin/ca-certs.cjs") as {
|
||||
harvestSystemCerts: (tls?: unknown) => string[];
|
||||
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
||||
readUserCerts: (
|
||||
fs: unknown,
|
||||
path: unknown,
|
||||
value: string | null,
|
||||
managedPath: string | null,
|
||||
) => string[];
|
||||
buildBundle: (input: {
|
||||
systemCerts: string[];
|
||||
userPems?: string[];
|
||||
}) => string;
|
||||
countCerts: (pems: string[]) => number;
|
||||
configureNodeExtraCaCerts: (
|
||||
env: Record<string, string>,
|
||||
deps?: { tls?: unknown; fs?: unknown },
|
||||
) => {
|
||||
action: string;
|
||||
path: string | null;
|
||||
systemCertCount: number;
|
||||
userCertCount: number;
|
||||
};
|
||||
shouldWarnApiUnavailable: (
|
||||
env: Record<string, string>,
|
||||
deps?: { fs?: unknown; nodeVersion?: string },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const certSystem =
|
||||
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
||||
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
||||
|
||||
function fakeTls(certs: unknown) {
|
||||
return { getCACertificates: () => certs };
|
||||
}
|
||||
|
||||
describe("ca-certs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("harvestSystemCerts", () => {
|
||||
it("returns only PEM strings from the system store", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
||||
).toEqual([certSystem]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates is unavailable", () => {
|
||||
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates throws", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts({
|
||||
getCACertificates: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserBundle", () => {
|
||||
it("returns PEM contents for a PEM file", () => {
|
||||
const p = join(dir, "user.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
||||
});
|
||||
|
||||
it("returns null for a non-PEM (DER) file", () => {
|
||||
const p = join(dir, "user.der");
|
||||
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing file and for null path", () => {
|
||||
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
||||
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips non-certificate sections such as private keys", () => {
|
||||
// Combined cert+key files (nginx/haproxy style) are common; the key
|
||||
// must never reach the managed bundle.
|
||||
const p = join(dir, "combined.pem");
|
||||
writeFileSync(
|
||||
p,
|
||||
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
||||
);
|
||||
const out = caCerts.readUserBundle(fs, p);
|
||||
expect(out).toContain("USER");
|
||||
expect(out).not.toContain("PRIVATE KEY");
|
||||
expect(out).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("keeps certificates-only files verbatim", () => {
|
||||
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
||||
const p = join(dir, "clean.pem");
|
||||
writeFileSync(p, `${certUser}\n${certSystem}`);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("returns null for a BEGIN marker without a complete block", () => {
|
||||
const p = join(dir, "truncated.pem");
|
||||
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserCerts", () => {
|
||||
it("reads a single PEM file path", () => {
|
||||
const p = join(dir, "corp.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
||||
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
||||
const a = join(dir, "a.pem");
|
||||
const b = join(dir, "b.pem");
|
||||
writeFileSync(a, certUser);
|
||||
writeFileSync(b, certSystem);
|
||||
expect(
|
||||
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
||||
).toEqual([certUser, certSystem]);
|
||||
});
|
||||
|
||||
it("skips missing segments in a delimited value", () => {
|
||||
const a = join(dir, "a.pem");
|
||||
writeFileSync(a, certUser);
|
||||
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
||||
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("excludes the managed bundle from user certs", () => {
|
||||
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
||||
writeFileSync(managed, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty value", () => {
|
||||
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundle", () => {
|
||||
it("merges user PEMs before system certs", () => {
|
||||
expect(
|
||||
caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
}),
|
||||
).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
||||
// certUser has no trailing newline, so this proves the boundary fix.
|
||||
const merged = caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
});
|
||||
expect(merged).not.toContain(
|
||||
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles no user PEMs", () => {
|
||||
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
||||
certSystem,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureNodeExtraCaCerts", () => {
|
||||
it("writes a managed bundle and points the env var at it", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.action).toBe("written");
|
||||
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
||||
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
||||
const userPath = join(dir, "corp.pem");
|
||||
writeFileSync(userPath, certUser);
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: userPath,
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.userCertCount).toBe(1);
|
||||
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written).toContain("USER");
|
||||
expect(written).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("reports unchanged and skips rewrite on the second run", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("written");
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("does not re-append when the user already points at the managed bundle", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const first = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
const env2: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: first,
|
||||
};
|
||||
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
||||
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("no-ops when no system certs are available", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
||||
expect(out.action).toBe("no-system-certs");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
||||
expect(out.action).toBe("api-unavailable");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports write-failed when the bundle cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
fs: failingFs,
|
||||
});
|
||||
expect(out.action).toBe("write-failed");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a stale bundle when the rewrite fails", () => {
|
||||
// First run writes the bundle normally.
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
|
||||
// Second run: writes fail, but the stale bundle is still readable.
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env2: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
||||
// A different system cert forces a rewrite attempt (not "unchanged").
|
||||
tls: fakeTls([certUser]),
|
||||
fs: failingFs,
|
||||
});
|
||||
|
||||
expect(out.action).toBe("write-failed-reused");
|
||||
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCerts", () => {
|
||||
it("counts individual certificates, not files", () => {
|
||||
// One file holding two certs must report 2, not 1.
|
||||
const twoInOne = `${certUser}\n${certSystem}`;
|
||||
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
||||
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
||||
expect(caCerts.countCerts([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnApiUnavailable", () => {
|
||||
it("warns once per Node version, then stays quiet", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { nodeVersion: "22.1.0" };
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms when the Node version changes", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still warns when the stamp cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
||||
// Bookkeeping failure must never suppress the diagnostic.
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SkillConfig,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
resolvePluginConfigSearchPaths,
|
||||
@@ -50,6 +52,8 @@ import {
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -184,6 +188,31 @@ function removePathIfExists(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cline access tokens expire between app launches, so account requests must
|
||||
// resolve through the refresh-aware OAuth manager instead of reading the
|
||||
// persisted token directly. A single shared instance keeps concurrent account
|
||||
// requests single-flight; the refresh token is single-use, so parallel
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
async function resolveFreshClineAuthToken(
|
||||
manager: ProviderSettingsManager,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
providerId: "cline",
|
||||
});
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the persisted token; the account request surfaces the
|
||||
// auth failure to the caller.
|
||||
}
|
||||
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
|
||||
}
|
||||
|
||||
async function listSessionsFromSidecarManager(
|
||||
ctx: SidecarContext,
|
||||
limit: number,
|
||||
@@ -558,7 +587,7 @@ async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
@@ -756,7 +785,13 @@ export async function handleCommand(
|
||||
|
||||
// ── Process context ───────────────────────────────────────────────
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot: ctx.workspaceRoot, cwd: ctx.workspaceRoot };
|
||||
return {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
homeDir: homedir(),
|
||||
platform: process.platform,
|
||||
appVersion: packageJson.version,
|
||||
};
|
||||
}
|
||||
if (command === "get_chat_ws_endpoint") {
|
||||
return "";
|
||||
@@ -953,7 +988,7 @@ export async function handleCommand(
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
getAuthToken: async () => resolveFreshClineAuthToken(manager),
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
|
||||
@@ -9,7 +9,7 @@ tauri-build = { version = "2.0.0", features = [] }
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.1", features = [] }
|
||||
tauri = { version = "2.11.1", features = ["macos-private-api", "tray-icon"] }
|
||||
rfd = "0.15"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, RunEvent, State};
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{Manager, RunEvent, State, WindowEvent};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppContext {
|
||||
@@ -473,6 +475,51 @@ fn open_mcp_settings_file() -> Result<String, String> {
|
||||
Ok(settings_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Begin an OS-level drag of the calling window (used by the floating pet so it
|
||||
/// can be dragged anywhere on screen without window decorations).
|
||||
#[tauri::command]
|
||||
fn start_pet_drag(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
window.start_dragging().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Show the floating pet window and (re)assert its always-on-top / all-Spaces
|
||||
/// presence so it floats above other apps even when the main window is hidden.
|
||||
#[tauri::command]
|
||||
fn show_pet(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(pet) = app.get_webview_window("pet") {
|
||||
pet.show().map_err(|e| e.to_string())?;
|
||||
let _ = pet.set_always_on_top(true);
|
||||
let _ = pet.set_visible_on_all_workspaces(true);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide the floating pet window (its dismiss button and the settings toggle).
|
||||
#[tauri::command]
|
||||
fn hide_pet(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(pet) = app.get_webview_window("pet") {
|
||||
pet.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_pet_visible(app: tauri::AppHandle) -> bool {
|
||||
app.get_webview_window("pet")
|
||||
.and_then(|pet| pet.is_visible().ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Bring the main window back after it was hidden by closing it.
|
||||
#[tauri::command]
|
||||
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
main.show().map_err(|e| e.to_string())?;
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let desktop_backend = Arc::new(DesktopBackendState::default());
|
||||
let launch_cwd = std::env::current_dir()
|
||||
@@ -502,13 +549,63 @@ fn main() {
|
||||
eprintln!("[desktop-backend] health check failed: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
// Tray icon so the app can keep running after the main window is
|
||||
// closed, with explicit Show / Quit actions.
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
let show_item =
|
||||
MenuItem::with_id(app, "show_window", "Show Window", true, None::<&str>)?;
|
||||
let quit_item =
|
||||
MenuItem::with_id(app, "quit", "Quit Cline Code", true, None::<&str>)?;
|
||||
let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||
TrayIconBuilder::with_id("cline-tray")
|
||||
.icon(icon)
|
||||
.tooltip("Cline Code")
|
||||
.menu(&tray_menu)
|
||||
.show_menu_on_left_click(true)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show_window" => {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
}
|
||||
|
||||
// Keep the pet floating above other apps and on every Space, so it
|
||||
// stays visible even when the main window is minimized or hidden.
|
||||
if let Some(pet) = app.get_webview_window("pet") {
|
||||
let _ = pet.set_always_on_top(true);
|
||||
let _ = pet.set_visible_on_all_workspaces(true);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_desktop_backend_endpoint,
|
||||
pick_workspace_directory,
|
||||
open_mcp_settings_file
|
||||
open_mcp_settings_file,
|
||||
start_pet_drag,
|
||||
show_pet,
|
||||
hide_pet,
|
||||
is_pet_visible,
|
||||
show_main_window
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
// Closing the main window hides it and keeps the app (and sidecar)
|
||||
// running in the background; the tray or Dock reopens it, and
|
||||
// Cmd+Q / the tray Quit item performs the real shutdown.
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri app")
|
||||
.run(|app_handle, event| match event {
|
||||
@@ -518,6 +615,13 @@ fn main() {
|
||||
.inner()
|
||||
.stop();
|
||||
}
|
||||
// Clicking the Dock icon on macOS reopens the hidden main window.
|
||||
RunEvent::Reopen { .. } => {
|
||||
if let Some(main) = app_handle.get_webview_window("main") {
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"frontendDist": "../webview/out"
|
||||
},
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
@@ -17,6 +18,21 @@
|
||||
"width": 1500,
|
||||
"height": 980,
|
||||
"resizable": true
|
||||
},
|
||||
{
|
||||
"label": "pet",
|
||||
"title": "Nyan Pet",
|
||||
"width": 184,
|
||||
"height": 120,
|
||||
"resizable": false,
|
||||
"transparent": true,
|
||||
"decorations": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": false,
|
||||
"focus": false,
|
||||
"maximizable": false,
|
||||
"minimizable": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -19,6 +19,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero heading cycling verb (components/views/chat/welcome-chat.tsx) */
|
||||
@keyframes hero-word-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0.42em);
|
||||
filter: blur(6px);
|
||||
}
|
||||
60% {
|
||||
filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-word-char {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
|
||||
color: var(--brand-violet);
|
||||
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gradient fill per character. The clip lives on each animated span (not a
|
||||
* shared parent) because WebKit — used by the Tauri webview on macOS — drops
|
||||
* the parent's background when a child paints on its own transform/filter
|
||||
* layer, which would leave the animating letters blank. The -webkit- prefixes
|
||||
* are required by WebKit; @supports keeps the solid fallback above otherwise.
|
||||
*/
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
.hero-word-char {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--brand-periwinkle),
|
||||
var(--brand-violet) 55%,
|
||||
var(--brand-magenta)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-word-char {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0% {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AgentHeader } from "@/components/agent-header";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { NyanCat, PetWindowView } from "@/components/nyan-cat";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -29,13 +30,20 @@ import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
import { useSessionHistory } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import {
|
||||
readChatBackground,
|
||||
subscribeChatBackground,
|
||||
} from "@/lib/chat-background";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
|
||||
import { getCurrentWindowLabel, isTauri } from "@/lib/pet-window";
|
||||
import {
|
||||
getSessionMetadataTitle,
|
||||
type SessionHistoryItem,
|
||||
@@ -43,6 +51,7 @@ import {
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
@@ -71,6 +80,35 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
// Both the main and the floating-pet windows load this same page; branch on
|
||||
// the Tauri window label so the pet window renders only the pet. Render
|
||||
// nothing until resolved so the client-only branches never hydrate wrong.
|
||||
const [windowKind, setWindowKind] = useState<"pending" | "main" | "pet">(
|
||||
"pending",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getCurrentWindowLabel().then((label) => {
|
||||
if (active) {
|
||||
setWindowKind(label === "pet" ? "pet" : "main");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (windowKind === "pending") {
|
||||
return null;
|
||||
}
|
||||
if (windowKind === "pet") {
|
||||
return <PetWindowView />;
|
||||
}
|
||||
return <MainApp />;
|
||||
}
|
||||
|
||||
function MainApp() {
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>("General");
|
||||
@@ -86,6 +124,10 @@ export default function Home() {
|
||||
return watchSystemHubTheme();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void syncDesktopWindowTitle();
|
||||
}, []);
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
const id = makeThreadId();
|
||||
setThreads((prev) => [...prev, { id }]);
|
||||
@@ -219,63 +261,69 @@ export default function Home() {
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
isHomeActive={
|
||||
view === "chat" &&
|
||||
!activeThread?.historySession &&
|
||||
!activeThread?.hasStarted
|
||||
}
|
||||
onHome={handleHome}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
{!isTauri() ? <NyanCat /> : null}
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
isHomeActive={
|
||||
view === "chat" &&
|
||||
!activeThread?.historySession &&
|
||||
!activeThread?.hasStarted
|
||||
}
|
||||
onHome={handleHome}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={setSettingsSection}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={setSettingsSection}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,14 +389,26 @@ function ChatThreadPane({
|
||||
string | null
|
||||
>(null);
|
||||
const [gitBranch, setGitBranch] = useState("no-git");
|
||||
const [chatBackground, setChatBackground] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setChatBackground(readChatBackground());
|
||||
return subscribeChatBackground(() =>
|
||||
setChatBackground(readChatBackground()),
|
||||
);
|
||||
}, []);
|
||||
const [providerCredentials, setProviderCredentials] = useState<
|
||||
Record<string, { apiKey: string }>
|
||||
>({});
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
// History paths lead each merge: they are ordered by session recency, so
|
||||
// stored or stale entries only append after them.
|
||||
const [workspaces, setWorkspaces] = useState<string[]>(() =>
|
||||
mergeWorkspacePaths(
|
||||
readWorkspaceSelectionFromWindow().workspaces,
|
||||
knownWorkspacePaths,
|
||||
filterWorkspacePaths(
|
||||
mergeWorkspacePaths(
|
||||
knownWorkspacePaths,
|
||||
readWorkspaceSelectionFromWindow().workspaces,
|
||||
),
|
||||
),
|
||||
);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
|
||||
@@ -366,7 +426,9 @@ function ChatThreadPane({
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
|
||||
const merged = filterWorkspacePaths(
|
||||
mergeWorkspacePaths(knownWorkspacePaths, current),
|
||||
);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
@@ -508,7 +570,12 @@ function ChatThreadPane({
|
||||
workspaceRef.current.cwd ||
|
||||
""
|
||||
).trim();
|
||||
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
|
||||
// The active workspace can be an excluded path (restored session,
|
||||
// process cwd fallback); it renders via its own registration in the
|
||||
// selector and welcome screen instead of joining the catalog.
|
||||
return filterWorkspacePaths(
|
||||
mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]),
|
||||
);
|
||||
},
|
||||
[knownWorkspacePaths],
|
||||
);
|
||||
@@ -518,7 +585,7 @@ function ChatThreadPane({
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, results);
|
||||
const merged = mergeWorkspacePaths(results, current);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
@@ -562,7 +629,9 @@ function ChatThreadPane({
|
||||
workspaceRoot: nextWorkspace,
|
||||
cwd: nextWorkspace,
|
||||
}));
|
||||
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
|
||||
setWorkspaces((prev) =>
|
||||
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
|
||||
);
|
||||
|
||||
// Fire git branch + workspace list refresh in the background
|
||||
desktopClient
|
||||
@@ -1045,68 +1114,86 @@ function ChatThreadPane({
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={workspaceContextValue}>
|
||||
<div
|
||||
className={
|
||||
isWelcomeState
|
||||
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
>
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) setShowDiffView(true);
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{chatBackground ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url("${chatBackground}")` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-background/80"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<WelcomeScreen
|
||||
active={isWelcomeState}
|
||||
body={
|
||||
showDiffView ? (
|
||||
<DiffView
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
onAnswerAskQuestion={handleAnswerAskQuestion}
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
pendingAskQuestions={pendingAskQuestions}
|
||||
sessionId={displayedSessionId}
|
||||
streamingMessageId={activeAssistantMessageId}
|
||||
isSessionSwitching={displayedIsSwitching}
|
||||
status={displayedStatus}
|
||||
/>
|
||||
)
|
||||
<div
|
||||
className={
|
||||
isWelcomeState
|
||||
? "relative z-10 grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "relative z-10 grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
composer={composer}
|
||||
onStartChat={setPromptInput}
|
||||
quickActions={[]}
|
||||
/>
|
||||
>
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) setShowDiffView(true);
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<WelcomeScreen
|
||||
active={isWelcomeState}
|
||||
body={
|
||||
showDiffView ? (
|
||||
<DiffView
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
onAnswerAskQuestion={handleAnswerAskQuestion}
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
pendingAskQuestions={pendingAskQuestions}
|
||||
sessionId={displayedSessionId}
|
||||
streamingMessageId={activeAssistantMessageId}
|
||||
isSessionSwitching={displayedIsSwitching}
|
||||
status={displayedStatus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
composer={composer}
|
||||
gitBranch={gitBranch}
|
||||
onListGitBranches={listGitBranches}
|
||||
onStartChat={setPromptInput}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -78,6 +82,9 @@ function sessionIsVisible(title: string): boolean {
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
@@ -173,4 +180,154 @@ describe("AgentSidebar session organization", () => {
|
||||
await click(buttonWithText("Load older projects"));
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows the signed-in account and active organization in the footer", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
photoUrl: "",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-1",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Beatrix");
|
||||
expect(container.textContent).toContain("Cline Bot Inc");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Cline Desktop");
|
||||
expect(container.textContent).not.toContain("Local");
|
||||
});
|
||||
|
||||
it("opens the Account settings section when the footer account row is clicked", async () => {
|
||||
const setView = vi.fn();
|
||||
const onSettingsSectionChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={onSettingsSectionChange}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={setView}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const accountButton = container.querySelector(
|
||||
'[aria-label="Account settings"]',
|
||||
);
|
||||
expect(accountButton).not.toBeNull();
|
||||
await click(accountButton as Element);
|
||||
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Account");
|
||||
expect(setView).toHaveBeenCalledWith("settings");
|
||||
});
|
||||
|
||||
it("shows the desktop app version in a popover when the Cline logo is clicked", async () => {
|
||||
const onHome = vi.fn();
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { appVersion: "1.2.3" };
|
||||
}
|
||||
throw new Error("No Cline account auth token found");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={onHome}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const logoButton = container.querySelector('[aria-label="Cline home"]');
|
||||
expect(logoButton).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Version 1.2.3");
|
||||
|
||||
await click(logoButton as Element);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Version 1.2.3");
|
||||
});
|
||||
expect(onHome).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith("get_process_context");
|
||||
});
|
||||
|
||||
it("falls back to a signed-out footer without account data", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Cline Desktop");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Local");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
PanelLeftOpen,
|
||||
@@ -64,6 +63,11 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
@@ -71,11 +75,13 @@ import {
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
@@ -172,6 +178,14 @@ export function AgentSidebar({
|
||||
}) {
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
const { user, activeOrganization } = useAccount();
|
||||
const { displayName, email } = user || {};
|
||||
const username = displayName?.split(" ")?.[0] || email?.split("@")?.[0];
|
||||
const accountName = username?.trim() || "Cline Desktop";
|
||||
const accountScope = user
|
||||
? (activeOrganization?.name ?? "Personal")
|
||||
: undefined;
|
||||
const accountInitial = accountName.charAt(0).toUpperCase();
|
||||
const {
|
||||
deleteThread: deleteHistoryThread,
|
||||
forkThread: forkHistoryThread,
|
||||
@@ -205,6 +219,26 @@ export function AgentSidebar({
|
||||
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
|
||||
const loadAppVersion = useCallback(async () => {
|
||||
try {
|
||||
const context = await desktopClient.invoke<{ appVersion?: unknown }>(
|
||||
"get_process_context",
|
||||
);
|
||||
const version =
|
||||
typeof context?.appVersion === "string"
|
||||
? context.appVersion.trim()
|
||||
: "";
|
||||
setAppVersion(version || null);
|
||||
} catch {
|
||||
// Leave the version hidden; an older sidecar build has no appVersion.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAppVersion();
|
||||
}, [loadAppVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed && searchOpen) {
|
||||
@@ -446,14 +480,31 @@ export function AgentSidebar({
|
||||
isCollapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={openHome}
|
||||
type="button"
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (open && !appVersion) {
|
||||
void loadAppVersion();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="flex items-center gap-2 rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
type="button"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-52 p-3" side="bottom">
|
||||
<p className="text-sm font-medium">Cline Code</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{appVersion ? `Version ${appVersion}` : "Version unavailable"}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
|
||||
@@ -465,13 +516,13 @@ export function AgentSidebar({
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
aria-label="Home"
|
||||
aria-label="New Session"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
title="New Session"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Home className="size-4" />
|
||||
{!isCollapsed ? "Home" : null}
|
||||
<Plus className="size-4" />
|
||||
{!isCollapsed ? "New Session" : null}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -542,17 +593,6 @@ export function AgentSidebar({
|
||||
</Button>
|
||||
{sortMenu}
|
||||
{filterMenu}
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
onClick={openNewThread}
|
||||
size="icon"
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen ? (
|
||||
@@ -679,36 +719,47 @@ export function AgentSidebar({
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
view === "settings" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
{view !== "settings" && (
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
)}
|
||||
{!isCollapsed ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
C
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<button
|
||||
aria-label="Account settings"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
view === "settings" &&
|
||||
settingsSection === "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
onClick={() => openSettingsSection("Account")}
|
||||
title={user?.email || undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex gap-2 items-center">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
{accountInitial}
|
||||
</span>
|
||||
<span className="block truncate text-sm font-medium">
|
||||
Cline Desktop
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Local
|
||||
{accountName}
|
||||
<span className="pl-1 truncate text-[11px] text-muted-foreground">
|
||||
{accountScope}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -881,7 +932,7 @@ function ThreadItem({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
|
||||
isActive
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { AppWindow, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_NYAN_PET_SRC,
|
||||
getNyanPetSrc,
|
||||
subscribeNyanPet,
|
||||
} from "@/lib/nyan-pet";
|
||||
import { hidePet, showMainWindow, startPetDrag } from "@/lib/pet-window";
|
||||
|
||||
const NYAN_WIDTH = 160;
|
||||
const NYAN_HEIGHT = 96;
|
||||
const MARGIN = 32;
|
||||
|
||||
/**
|
||||
* Shared pet media: the current gif source (kept in sync with Settings) plus an
|
||||
* audio element that plays only while the pet is hovered or being dragged.
|
||||
*/
|
||||
function useNyanPetMedia() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [petSrc, setPetSrc] = useState(DEFAULT_NYAN_PET_SRC);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPetSrc(getNyanPetSrc());
|
||||
return subscribeNyanPet(() => setPetSrc(getNyanPetSrc()));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
if (hovering || dragging) {
|
||||
void audio.play().catch(() => {});
|
||||
} else {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
}, [hovering, dragging]);
|
||||
|
||||
return { audioRef, petSrc, hovering, setHovering, dragging, setDragging };
|
||||
}
|
||||
|
||||
/**
|
||||
* In-page pet used in plain web/dev mode (no Tauri). Free-floating and draggable
|
||||
* within the window; its theme song plays while hovered or dragged. In the
|
||||
* desktop app the pet lives in its own always-on-top window (see PetWindowView).
|
||||
*/
|
||||
export function NyanCat() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [position, setPosition] = useState({ x: MARGIN, y: MARGIN });
|
||||
const { audioRef, petSrc, dragging, setDragging, setHovering } =
|
||||
useNyanPetMedia();
|
||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition({
|
||||
x: Math.max(MARGIN, window.innerWidth - NYAN_WIDTH - MARGIN),
|
||||
y: Math.max(MARGIN, window.innerHeight - NYAN_HEIGHT - MARGIN),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = Math.max(0, window.innerWidth - NYAN_WIDTH);
|
||||
const maxY = Math.max(0, window.innerHeight - NYAN_HEIGHT);
|
||||
return {
|
||||
x: Math.min(Math.max(0, x), maxX),
|
||||
y: Math.min(Math.max(0, y), maxY),
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
const handleMove = (event: PointerEvent) => {
|
||||
const offset = dragOffsetRef.current;
|
||||
if (!offset) {
|
||||
return;
|
||||
}
|
||||
setPosition(clamp(event.clientX - offset.x, event.clientY - offset.y));
|
||||
};
|
||||
const stop = () => {
|
||||
dragOffsetRef.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
};
|
||||
}, [dragging, clamp, setDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () =>
|
||||
setPosition((current) => clamp(current.x, current.y));
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [clamp]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group fixed z-50 cursor-grab select-none active:cursor-grabbing"
|
||||
onPointerDown={(event) => {
|
||||
if ((event.target as HTMLElement).closest("[data-nyan-control]")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragOffsetRef.current = {
|
||||
x: event.clientX - position.x,
|
||||
y: event.clientY - position.y,
|
||||
};
|
||||
setDragging(true);
|
||||
}}
|
||||
onPointerEnter={() => setHovering(true)}
|
||||
onPointerLeave={() => setHovering(false)}
|
||||
style={{ left: position.x, top: position.y, width: NYAN_WIDTH }}
|
||||
>
|
||||
<button
|
||||
aria-label="Hide Nyan Cat"
|
||||
className="absolute -right-2 -top-2 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
|
||||
data-nyan-control=""
|
||||
onClick={() => setVisible(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
{/* biome-ignore lint/performance/noImgElement: static public asset, not statically optimizable */}
|
||||
<img
|
||||
alt="Desktop pet"
|
||||
className="pointer-events-none w-full drop-shadow-lg"
|
||||
draggable={false}
|
||||
height={NYAN_HEIGHT}
|
||||
src={petSrc}
|
||||
width={NYAN_WIDTH}
|
||||
/>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: decorative background music */}
|
||||
<audio loop preload="auto" ref={audioRef} src="/nyantune.mp3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pet as rendered inside its own transparent, always-on-top Tauri window.
|
||||
* Dragging moves the OS window (so it can go anywhere on screen, even when the
|
||||
* main window is minimized), and the dismiss button hides the window.
|
||||
*/
|
||||
export function PetWindowView() {
|
||||
const { audioRef, petSrc, dragging, setDragging, setHovering } =
|
||||
useNyanPetMedia();
|
||||
|
||||
// Make the window chrome see-through so only the pet shows.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const prevRoot = root.style.background;
|
||||
const prevBody = body.style.background;
|
||||
root.style.background = "transparent";
|
||||
body.style.background = "transparent";
|
||||
return () => {
|
||||
root.style.background = prevRoot;
|
||||
body.style.background = prevBody;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The OS drag can swallow the pointerup; reset on any pointer release.
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
const stop = () => setDragging(false);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
return () => {
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
};
|
||||
}, [dragging, setDragging]);
|
||||
|
||||
return (
|
||||
<div className="group fixed inset-0 flex select-none items-center justify-center">
|
||||
<div
|
||||
className="relative cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={(event) => {
|
||||
if ((event.target as HTMLElement).closest("[data-nyan-control]")) {
|
||||
return;
|
||||
}
|
||||
setDragging(true);
|
||||
void startPetDrag();
|
||||
}}
|
||||
onPointerEnter={() => setHovering(true)}
|
||||
onPointerLeave={() => setHovering(false)}
|
||||
>
|
||||
<button
|
||||
aria-label="Open Cline window"
|
||||
className="absolute -left-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
|
||||
data-nyan-control=""
|
||||
onClick={() => void showMainWindow()}
|
||||
title="Open Cline"
|
||||
type="button"
|
||||
>
|
||||
<AppWindow className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Hide desktop pet"
|
||||
className="absolute -right-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
|
||||
data-nyan-control=""
|
||||
onClick={() => void hidePet()}
|
||||
title="Hide pet"
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
{/* biome-ignore lint/performance/noImgElement: static public asset, not statically optimizable */}
|
||||
<img
|
||||
alt="Desktop pet"
|
||||
className="pointer-events-none drop-shadow-lg"
|
||||
draggable={false}
|
||||
height={NYAN_HEIGHT}
|
||||
src={petSrc}
|
||||
width={NYAN_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: decorative background music */}
|
||||
<audio loop preload="auto" ref={audioRef} src="/nyantune.mp3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -313,8 +313,8 @@ function Sidebar({
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
|
||||
@@ -13,14 +13,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,6 +34,7 @@ import {
|
||||
loadProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchableSelect } from "./searchable-select";
|
||||
import { WorkspaceSelector } from "./workspace-selector";
|
||||
|
||||
type ActiveMention = {
|
||||
@@ -1045,7 +1038,7 @@ export function ChatInputBar({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<div className="hidden flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<button
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
@@ -1087,7 +1080,6 @@ export function ChatInputBar({
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1097,7 +1089,7 @@ export function ChatInputBar({
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
@@ -1128,7 +1120,7 @@ export function ChatInputBar({
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
|
||||
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<div className="hidden max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
@@ -1181,7 +1173,6 @@ function ModelSelector({
|
||||
provider,
|
||||
model,
|
||||
isBusy,
|
||||
variant,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
@@ -1189,7 +1180,6 @@ function ModelSelector({
|
||||
provider: string;
|
||||
model: string;
|
||||
isBusy: boolean;
|
||||
variant: "conversation" | "welcome";
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
|
||||
@@ -1416,13 +1406,13 @@ function ModelSelector({
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
|
||||
<Combobox
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<SearchableSelect
|
||||
ariaLabel="Provider"
|
||||
disabled={isBusy || providers.length === 0}
|
||||
emptyLabel="No providers found."
|
||||
items={providers}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onSelect={(value) => {
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
@@ -1439,63 +1429,23 @@ function ModelSelector({
|
||||
onModelChange(firstModel);
|
||||
}
|
||||
}}
|
||||
placeholder="Provider"
|
||||
searchPlaceholder="Search providers"
|
||||
triggerClassName="max-w-28 text-[11px]"
|
||||
value={resolvedProvider}
|
||||
>
|
||||
<ComboboxInput
|
||||
aria-label="Provider"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-20",
|
||||
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || providers.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
showTrigger
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No providers found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
/>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<SearchableSelect
|
||||
ariaLabel="Model"
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyLabel="No models found."
|
||||
items={modelsForProvider}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onModelChange(value);
|
||||
}}
|
||||
onSelect={(value) => onModelChange(value)}
|
||||
placeholder="Model"
|
||||
searchPlaceholder="Search models"
|
||||
triggerClassName="max-w-52 text-[11px]"
|
||||
value={resolvedModel}
|
||||
>
|
||||
<ComboboxInput
|
||||
aria-label="Model"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-32",
|
||||
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
showTrigger
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A button-styled select whose menu is a searchable, filterable list — the same
|
||||
* interaction the workspace and branch pickers use. The trigger shows the
|
||||
* current value with no chevron; clicking it opens the popover.
|
||||
*/
|
||||
export function SearchableSelect({
|
||||
value,
|
||||
items,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyLabel = "No results",
|
||||
placeholder = "Select",
|
||||
icon,
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
placement = "top",
|
||||
}: {
|
||||
value: string;
|
||||
items: string[];
|
||||
onSelect: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyLabel?: string;
|
||||
placeholder?: string;
|
||||
icon?: ReactNode;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "end";
|
||||
placement?: "top" | "bottom";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click; reset the filter each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
// pointerdown in the capture phase so we still fire before a portaled menu
|
||||
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
|
||||
// calls preventDefault, which would otherwise suppress a mousedown listener.
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
|
||||
[items, search],
|
||||
);
|
||||
|
||||
const handleSelect = (item: string) => {
|
||||
if (item !== value) onSelect(item);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
triggerClassName,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
title={value}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
item === value ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => handleSelect(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{item}
|
||||
</span>
|
||||
{item === value && (
|
||||
<Check className="ml-2 size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
@@ -15,6 +15,9 @@ interface QuickAction {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
|
||||
const HERO_CYCLE_MS = 2600;
|
||||
|
||||
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
{
|
||||
id: "review-changes",
|
||||
@@ -30,33 +33,44 @@ const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function toWorkspaceName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "Workspace";
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "Workspace";
|
||||
}
|
||||
function HeroHeading() {
|
||||
const [verbIndex, setVerbIndex] = useState(0);
|
||||
|
||||
function workspaceLabels(paths: string[]): Map<string, string> {
|
||||
const segments = paths.map((path) =>
|
||||
path
|
||||
.trim()
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return new Map(
|
||||
paths.map((path, index) => {
|
||||
const parts = segments[index] ?? [];
|
||||
for (let depth = 1; depth <= parts.length; depth += 1) {
|
||||
const candidate = parts.slice(-depth).join("/");
|
||||
const matches = segments.filter(
|
||||
(other) => other.slice(-depth).join("/") === candidate,
|
||||
).length;
|
||||
if (matches === 1) return [path, candidate];
|
||||
}
|
||||
return [path, toWorkspaceName(path)];
|
||||
}),
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (media.matches) return;
|
||||
const interval = setInterval(() => {
|
||||
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
|
||||
}, HERO_CYCLE_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const verb = HERO_VERBS[verbIndex];
|
||||
|
||||
return (
|
||||
<h1
|
||||
id="hero-header"
|
||||
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
|
||||
>
|
||||
<span className="sr-only">What would you like to build?</span>
|
||||
<span aria-hidden="true">
|
||||
What would you like to{" "}
|
||||
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
|
||||
<span key={verb}>
|
||||
{verb.split("").map((char, index) => (
|
||||
<span
|
||||
className="hero-word-char"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
|
||||
key={`${verb}-${index}`}
|
||||
style={{ animationDelay: `${index * 45}ms` }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
?
|
||||
</span>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,12 +80,18 @@ export function WelcomeScreen({
|
||||
composer,
|
||||
onStartChat,
|
||||
quickActions,
|
||||
gitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
composer: ReactNode;
|
||||
onStartChat: (prompt: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
gitBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const {
|
||||
workspaceRoot,
|
||||
@@ -80,61 +100,13 @@ export function WelcomeScreen({
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
} = useWorkspace();
|
||||
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addingWorkspace, setAddingWorkspace] = useState(false);
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const next = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const workspacePath of workspaces) register(workspacePath);
|
||||
return [...next.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
const actions =
|
||||
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
|
||||
const labelsByWorkspace = useMemo(
|
||||
() => workspaceLabels(availableWorkspaces),
|
||||
[availableWorkspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void refreshWorkspaces();
|
||||
}, [active, refreshWorkspaces]);
|
||||
|
||||
const handleSelectWorkspace = useCallback(
|
||||
async (path: string) => {
|
||||
if (
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot) ||
|
||||
switchingWorkspace
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSwitchingWorkspace(path);
|
||||
try {
|
||||
await switchWorkspace(path);
|
||||
} finally {
|
||||
setSwitchingWorkspace(null);
|
||||
}
|
||||
},
|
||||
[switchWorkspace, switchingWorkspace, workspaceRoot],
|
||||
);
|
||||
|
||||
const handleAddWorkspace = useCallback(async () => {
|
||||
if (addingWorkspace) return;
|
||||
setAddingWorkspace(true);
|
||||
try {
|
||||
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
|
||||
if (selected) await switchWorkspace(selected);
|
||||
} finally {
|
||||
setAddingWorkspace(false);
|
||||
}
|
||||
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -154,60 +126,25 @@ export function WelcomeScreen({
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
|
||||
What would you like to build?
|
||||
</h1>
|
||||
<HeroHeading />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
|
||||
<legend className="sr-only">Workspaces</legend>
|
||||
{availableWorkspaces.map((path) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot);
|
||||
const isSwitching = switchingWorkspace === path;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
disabled={Boolean(switchingWorkspace)}
|
||||
key={path}
|
||||
onClick={() => void handleSelectWorkspace(path)}
|
||||
title={path}
|
||||
type="button"
|
||||
>
|
||||
{isSwitching
|
||||
? "Switching..."
|
||||
: (labelsByWorkspace.get(path) ??
|
||||
toWorkspaceName(path))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
|
||||
disabled={addingWorkspace}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
type="button"
|
||||
>
|
||||
{addingWorkspace ? (
|
||||
<FolderPlus className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
New project
|
||||
</button>
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onPickWorkspaceDirectory={pickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={refreshWorkspaces}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Folder, GitBranch, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
function formatWorkspacePath(path: string): string {
|
||||
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
|
||||
const linuxHome = path.match(/^\/home\/[^/]+\/(.*)$/);
|
||||
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
|
||||
const windowsHome = path.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
|
||||
if (windowsHome) {
|
||||
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
|
||||
return tail ? `~/${tail}` : "~";
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function workspaceName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "workspace";
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "workspace";
|
||||
}
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const PANEL_CLASS =
|
||||
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePicker({
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
onRefreshWorkspaces,
|
||||
onSwitchWorkspace,
|
||||
onPickWorkspaceDirectory,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
const normalizedWorkspaceRoot = useMemo(
|
||||
() => normalizeWorkspacePath(workspaceRoot),
|
||||
[workspaceRoot],
|
||||
);
|
||||
|
||||
// Refresh the catalog and clear the filter each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
void onRefreshWorkspaces();
|
||||
}, [open, onRefreshWorkspaces]);
|
||||
|
||||
// The active workspace can be an excluded path (restored session, process
|
||||
// cwd fallback); register it explicitly so it stays visible while active.
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const byNormalizedPath = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed)
|
||||
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const path of workspaces) register(path);
|
||||
return [...byNormalizedPath.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter((path) =>
|
||||
path.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSelect = async (path: string) => {
|
||||
const next = path.trim();
|
||||
if (!next || normalizeWorkspacePath(next) === normalizedWorkspaceRoot) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (switching) return;
|
||||
setSwitching(true);
|
||||
const switched = await onSwitchWorkspace(next);
|
||||
setSwitching(false);
|
||||
if (switched) onClose();
|
||||
};
|
||||
|
||||
const handleAddWorkspace = async () => {
|
||||
if (picking || switching) return;
|
||||
setPicking(true);
|
||||
try {
|
||||
const picked = await onPickWorkspaceDirectory(workspaceRoot || undefined);
|
||||
if (picked?.trim()) await handleSelect(picked.trim());
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={TRIGGER_CLASS}
|
||||
onClick={onToggle}
|
||||
title={workspaceRoot}
|
||||
type="button"
|
||||
>
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-44 truncate">
|
||||
{workspaceName(workspaceRoot)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<SearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search workspaces"
|
||||
value={search}
|
||||
/>
|
||||
<div className="p-1.5">
|
||||
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No workspaces found
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkspaces.map((path) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(path) === normalizedWorkspaceRoot;
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
|
||||
isActive ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
disabled={switching}
|
||||
key={path}
|
||||
onClick={() => void handleSelect(path)}
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{formatWorkspacePath(path)}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="ml-2 size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-0.5 w-full justify-start text-xs text-muted-foreground"
|
||||
disabled={switching || picking}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{picking ? "Opening folder picker..." : "Add project..."}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchPicker({
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
currentBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
currentBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
|
||||
// Load branches fresh each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setSearch("");
|
||||
setLoading(true);
|
||||
onListGitBranches()
|
||||
.then((payload) => {
|
||||
if (!cancelled) setBranches(payload.branches);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, onListGitBranches]);
|
||||
|
||||
const hasGit = currentBranch !== "no-git";
|
||||
const branchLabel = hasGit ? currentBranch : "No branch";
|
||||
|
||||
const filteredBranches = branches.filter((branch) =>
|
||||
branch.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSelect = async (branch: string) => {
|
||||
if (branch === currentBranch) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (switching) return;
|
||||
setSwitching(true);
|
||||
const switched = await onSwitchGitBranch(branch);
|
||||
setSwitching(false);
|
||||
if (switched) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
|
||||
onClick={onToggle}
|
||||
title={branchLabel}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{branchLabel}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<SearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search branches"
|
||||
value={search}
|
||||
/>
|
||||
<div className="p-1.5">
|
||||
{loading ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
|
||||
currentBranch === branch
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
disabled={switching}
|
||||
key={branch}
|
||||
onClick={() => void handleSelect(branch)}
|
||||
variant="ghost"
|
||||
>
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{branch}
|
||||
</span>
|
||||
{currentBranch === branch && (
|
||||
<Check className="ml-auto size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeWorkspaceControls({
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
onRefreshWorkspaces,
|
||||
onSwitchWorkspace,
|
||||
onPickWorkspaceDirectory,
|
||||
currentBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
currentBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close whichever menu is open when clicking outside the control row.
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, [openMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2" ref={containerRef}>
|
||||
<WorkspacePicker
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "workspace" ? null : "workspace",
|
||||
)
|
||||
}
|
||||
open={openMenu === "workspace"}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<BranchPicker
|
||||
currentBranch={currentBranch}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) => (current === "branch" ? null : "branch"))
|
||||
}
|
||||
open={openMenu === "branch"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,4 +79,30 @@ describe("WorkspaceSelector", () => {
|
||||
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the active workspace even when the catalog excludes it", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/Users/beatrix/Desktop"
|
||||
workspaces={["/workspace/one"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("~/Desktop");
|
||||
expect(container.textContent).toContain("/workspace/one");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
function formatWorkspacePath(path: string): string {
|
||||
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
@@ -19,17 +20,6 @@ function formatWorkspacePath(path: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function WorkspaceSelector({
|
||||
currentBranch,
|
||||
workspaceRoot,
|
||||
@@ -181,7 +171,20 @@ export function WorkspaceSelector({
|
||||
b.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredWorkspaces = workspaces.filter((w) =>
|
||||
// The catalog excludes non-project paths (home, Desktop, ~/.cline), but an
|
||||
// explicitly opened workspace must stay visible while it is active.
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const byNormalizedPath = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed) byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const path of workspaces) register(path);
|
||||
return [...byNormalizedPath.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter((w) =>
|
||||
w.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
MAX_CHAT_BACKGROUND_BYTES,
|
||||
readChatBackground,
|
||||
setChatBackground,
|
||||
} from "@/lib/chat-background";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
DEFAULT_NYAN_PET_SRC,
|
||||
MAX_NYAN_PET_BYTES,
|
||||
readStoredPetGif,
|
||||
setStoredPetGif,
|
||||
} from "@/lib/nyan-pet";
|
||||
import { hidePet, isPetVisible, isTauri, showPet } from "@/lib/pet-window";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderCatalogResponse,
|
||||
@@ -581,7 +594,261 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<NyanPetSetting />
|
||||
<PetWindowToggle />
|
||||
<ChatBackgroundSetting />
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBackgroundSetting() {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [background, setBackground] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBackground(readChatBackground());
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback((file: File | undefined) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("Please choose an image file.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_CHAT_BACKGROUND_BYTES) {
|
||||
setError("That image is too large. Please pick one under 3 MB.");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === "string" ? reader.result : null;
|
||||
if (!dataUrl) {
|
||||
setError("Could not read that image.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setChatBackground(dataUrl);
|
||||
setBackground(dataUrl);
|
||||
} catch {
|
||||
setError("Could not save that image — it may be too large to store.");
|
||||
}
|
||||
};
|
||||
reader.onerror = () => setError("Could not read that image.");
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setChatBackground(null);
|
||||
setBackground(null);
|
||||
setError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Chat background
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Upload an image to show behind your chat conversation.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 max-[720px]:justify-end">
|
||||
<div className="flex h-14 w-24 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
|
||||
{background ? (
|
||||
// biome-ignore lint/performance/noImgElement: user-provided data URL, not statically optimizable
|
||||
<img
|
||||
alt="MCP background preview"
|
||||
className="h-full w-full object-cover"
|
||||
src={background}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
accept="image/gif,image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Upload image
|
||||
</Button>
|
||||
{background ? (
|
||||
<Button onClick={handleReset} type="button" variant="ghost">
|
||||
Reset
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PetWindowToggle() {
|
||||
const [inDesktopApp, setInDesktopApp] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
setInDesktopApp(true);
|
||||
void isPetVisible().then(setVisible);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(async (next: boolean) => {
|
||||
setVisible(next);
|
||||
if (next) {
|
||||
await showPet();
|
||||
} else {
|
||||
await hidePet();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Only meaningful in the desktop app, where the pet is its own OS window.
|
||||
if (!inDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Show floating pet
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Float the pet on top of your screen so it stays visible even when this
|
||||
window is minimized or closed.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Show floating pet"
|
||||
checked={visible}
|
||||
onCheckedChange={(checked) => void toggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NyanPetSetting() {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [petGif, setPetGif] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPetGif(readStoredPetGif());
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback((file: File | undefined) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("Please choose an image file — an animated GIF works best.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_NYAN_PET_BYTES) {
|
||||
setError("That image is too large. Please pick one under 3 MB.");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === "string" ? reader.result : null;
|
||||
if (!dataUrl) {
|
||||
setError("Could not read that image.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setStoredPetGif(dataUrl);
|
||||
setPetGif(dataUrl);
|
||||
} catch {
|
||||
setError("Could not save that image — it may be too large to store.");
|
||||
}
|
||||
};
|
||||
reader.onerror = () => setError("Could not read that image.");
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setStoredPetGif(null);
|
||||
setPetGif(null);
|
||||
setError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const previewSrc = petGif ?? DEFAULT_NYAN_PET_SRC;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">Desktop pet</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Replace Nyan Cat with your own GIF. It floats on top of the app, and
|
||||
its tune plays while you hover or drag it.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 max-[720px]:justify-end">
|
||||
<div className="flex h-14 w-20 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
|
||||
{/* biome-ignore lint/performance/noImgElement: user-provided data URL, not statically optimizable */}
|
||||
<img
|
||||
alt="Desktop pet preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
src={previewSrc}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
accept="image/gif,image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Upload GIF
|
||||
</Button>
|
||||
{petGif ? (
|
||||
<Button onClick={handleReset} type="button" variant="ghost">
|
||||
Reset
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ClineAccountUser } from "@cline/core";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
AccountProvider,
|
||||
isSignedOutAccountError,
|
||||
parseCachedAccountUser,
|
||||
useAccount,
|
||||
} from "./account-context";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
|
||||
|
||||
function makeUser(overrides: Partial<ClineAccountUser> = {}): ClineAccountUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
photoUrl: "",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { user, activeOrganization } = useAccount();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="account-name">{user?.displayName ?? "none"}</span>
|
||||
<span data-testid="account-org">
|
||||
{activeOrganization?.name ?? "none"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function probeText(testId: string): string | null | undefined {
|
||||
return container.querySelector(`[data-testid="${testId}"]`)?.textContent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("account context", () => {
|
||||
it("parses only cached payloads that look like an account user", () => {
|
||||
expect(parseCachedAccountUser(null)).toBeNull();
|
||||
expect(parseCachedAccountUser("not json")).toBeNull();
|
||||
expect(parseCachedAccountUser(JSON.stringify({ user: 42 }))).toBeNull();
|
||||
expect(
|
||||
parseCachedAccountUser(JSON.stringify({ user: makeUser() }))?.displayName,
|
||||
).toBe("Beatrix");
|
||||
});
|
||||
|
||||
it("classifies signed-out errors separately from transient failures", () => {
|
||||
expect(
|
||||
isSignedOutAccountError(new Error("No Cline account auth token found")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSignedOutAccountError(
|
||||
new Error(
|
||||
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isSignedOutAccountError(new Error("fetch failed"))).toBe(false);
|
||||
});
|
||||
|
||||
it("fetches the signed-in user on mount and caches the identity", async () => {
|
||||
invoke.mockResolvedValue(
|
||||
makeUser({
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-1",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<Probe />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probeText("account-name")).toBe("Beatrix");
|
||||
expect(probeText("account-org")).toBe("Cline Bot Inc");
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
expect(
|
||||
parseCachedAccountUser(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
)?.email,
|
||||
).toBe("beatrix@cline.bot");
|
||||
});
|
||||
|
||||
it("clears the cached identity when the account is signed out", async () => {
|
||||
window.localStorage.setItem(
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
JSON.stringify({ user: makeUser() }),
|
||||
);
|
||||
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<Probe />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probeText("account-name")).toBe("none");
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the cached identity when the refresh fails transiently", async () => {
|
||||
window.localStorage.setItem(
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
JSON.stringify({ user: makeUser() }),
|
||||
);
|
||||
invoke.mockRejectedValue(
|
||||
new Error("Desktop backend transport unavailable"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<Probe />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalled();
|
||||
});
|
||||
expect(probeText("account-name")).toBe("Beatrix");
|
||||
expect(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import type { ClineAccountOrganization, ClineAccountUser } from "@cline/core";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
export const ACCOUNT_IDENTITY_STORAGE_KEY = "cline.code.account-identity.v1";
|
||||
|
||||
const SIGNED_OUT_ERROR_MARKERS = [
|
||||
"No Cline account auth token found",
|
||||
"no longer valid",
|
||||
];
|
||||
|
||||
type AccountContextValue = {
|
||||
user: ClineAccountUser | null;
|
||||
organizations: ClineAccountOrganization[];
|
||||
activeOrganization: ClineAccountOrganization | null;
|
||||
refreshAccount: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AccountContext = createContext<AccountContextValue>({
|
||||
user: null,
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
refreshAccount: async () => undefined,
|
||||
});
|
||||
|
||||
export function parseCachedAccountUser(
|
||||
raw: string | null,
|
||||
): ClineAccountUser | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { user?: ClineAccountUser | null };
|
||||
const user = parsed?.user;
|
||||
if (!user || typeof user !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof user.email !== "string" &&
|
||||
typeof user.displayName !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedAccountUser(): ClineAccountUser | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseCachedAccountUser(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedAccountUser(user: ClineAccountUser | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (user) {
|
||||
window.localStorage.setItem(
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
JSON.stringify({ user }),
|
||||
);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCOUNT_IDENTITY_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Account identity still works for this session without the cache.
|
||||
}
|
||||
}
|
||||
|
||||
export function isSignedOutAccountError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return SIGNED_OUT_ERROR_MARKERS.some((marker) => message.includes(marker));
|
||||
}
|
||||
|
||||
export function AccountProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
|
||||
const refreshAccount = useCallback(async () => {
|
||||
try {
|
||||
const me = await desktopClient.invoke<ClineAccountUser>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
setUser(me ?? null);
|
||||
writeCachedAccountUser(me ?? null);
|
||||
} catch (error) {
|
||||
if (isSignedOutAccountError(error)) {
|
||||
setUser(null);
|
||||
writeCachedAccountUser(null);
|
||||
}
|
||||
// Transient failures (offline, sidecar restarting) keep the cached
|
||||
// identity rather than flashing a signed-out state.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Seed from the cached identity after mount so the signed-in name renders
|
||||
// without waiting on the network fetch, which revalidates it right after.
|
||||
// localStorage must not be read during the initial render: the server
|
||||
// renders the signed-out state, and a differing first client render would
|
||||
// be a hydration mismatch.
|
||||
setUser((current) => current ?? readCachedAccountUser());
|
||||
void refreshAccount();
|
||||
}, [refreshAccount]);
|
||||
|
||||
const value = useMemo<AccountContextValue>(() => {
|
||||
const organizations = user?.organizations ?? [];
|
||||
return {
|
||||
user,
|
||||
organizations,
|
||||
activeOrganization:
|
||||
organizations.find((organization) => organization.active) ?? null,
|
||||
refreshAccount,
|
||||
};
|
||||
}, [refreshAccount, user]);
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccount(): AccountContextValue {
|
||||
return useContext(AccountContext);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import type { SessionHookEvent } from "@/lib/session-diff";
|
||||
export type ProcessContext = {
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
platform?: string;
|
||||
appVersion?: string;
|
||||
};
|
||||
|
||||
export type AgentChunkEvent = {
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
import {
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
registerHostHomeDirectory,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
|
||||
@@ -475,6 +476,9 @@ export function useChatSession() {
|
||||
const ctx = await desktopClient.invoke<ProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
if (ctx.homeDir) {
|
||||
registerHostHomeDirectory(ctx.homeDir);
|
||||
}
|
||||
const rememberedWorkspace =
|
||||
readWorkspaceSelectionFromWindow().lastWorkspace;
|
||||
const validation = rememberedWorkspace
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export const CHAT_BACKGROUND_STORAGE_KEY = "cline-chat-background";
|
||||
const CHAT_BACKGROUND_CHANGE_EVENT = "cline:chat-background-changed";
|
||||
|
||||
/**
|
||||
* Upper bound on the uploaded background. localStorage caps around ~5 MB per
|
||||
* origin and base64 inflates bytes by ~33%, so keep the raw file under that
|
||||
* (shared with the pet gif, so leave headroom for both).
|
||||
*/
|
||||
export const MAX_CHAT_BACKGROUND_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
/** The custom chat background data URL, or null when none is set. */
|
||||
export function readChatBackground(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(CHAT_BACKGROUND_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (or clear, when passed null) the chat background and notify listeners.
|
||||
* Throws if the value exceeds the localStorage quota — callers should validate
|
||||
* size first and surface a friendly error.
|
||||
*/
|
||||
export function setChatBackground(dataUrl: string | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (dataUrl) {
|
||||
window.localStorage.setItem(CHAT_BACKGROUND_STORAGE_KEY, dataUrl);
|
||||
} else {
|
||||
window.localStorage.removeItem(CHAT_BACKGROUND_STORAGE_KEY);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(CHAT_BACKGROUND_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to background changes from this tab (settings edits) or another one
|
||||
* (native `storage` event). Returns a cleanup function.
|
||||
*/
|
||||
export function subscribeChatBackground(listener: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
const handle = () => listener();
|
||||
window.addEventListener(CHAT_BACKGROUND_CHANGE_EVENT, handle);
|
||||
window.addEventListener("storage", handle);
|
||||
return () => {
|
||||
window.removeEventListener(CHAT_BACKGROUND_CHANGE_EVENT, handle);
|
||||
window.removeEventListener("storage", handle);
|
||||
};
|
||||
}
|
||||
@@ -99,7 +99,7 @@ const RECONNECT_MAX_DELAY_MS = 4_000;
|
||||
// Commands that should be routed to Tauri's native invoke bridge instead of
|
||||
// the WebSocket transport — only applicable in the full Tauri app shell.
|
||||
// In sidecar/web mode these commands are handled by the sidecar over WebSocket.
|
||||
function isTauriAvailable(): boolean {
|
||||
export function isTauriAvailable(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { invoke, setTitle } = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
setTitle: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
isTauriAvailable: () => window.__TAURI_INTERNALS__ !== undefined,
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({ setTitle }),
|
||||
}));
|
||||
|
||||
async function importFresh() {
|
||||
vi.resetModules();
|
||||
return await import("./desktop-window-title");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invoke.mockReset();
|
||||
setTitle.mockClear();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
|
||||
delete (window as any).__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("desktop window title", () => {
|
||||
it("builds a versioned title, falling back to the base title without a version", async () => {
|
||||
const { buildDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
|
||||
await importFresh();
|
||||
expect(buildDesktopWindowTitle("1.2.3")).toBe(
|
||||
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
|
||||
);
|
||||
expect(buildDesktopWindowTitle(" 1.2.3 ")).toBe(
|
||||
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
|
||||
);
|
||||
expect(buildDesktopWindowTitle(undefined)).toBe(
|
||||
DEFAULT_DESKTOP_WINDOW_TITLE,
|
||||
);
|
||||
expect(buildDesktopWindowTitle("")).toBe(DEFAULT_DESKTOP_WINDOW_TITLE);
|
||||
});
|
||||
|
||||
it("does nothing outside the Tauri shell", async () => {
|
||||
const { syncDesktopWindowTitle } = await importFresh();
|
||||
await syncDesktopWindowTitle();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the native window title once the sidecar reports a version", async () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
|
||||
(window as any).__TAURI_INTERNALS__ = {};
|
||||
invoke.mockResolvedValue({
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
appVersion: "1.2.3",
|
||||
});
|
||||
|
||||
const { syncDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
|
||||
await importFresh();
|
||||
await syncDesktopWindowTitle();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_process_context");
|
||||
expect(setTitle).toHaveBeenCalledWith(
|
||||
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the title alone when the version is missing or the sidecar call fails", async () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
|
||||
(window as any).__TAURI_INTERNALS__ = {};
|
||||
invoke.mockResolvedValue({ workspaceRoot: "", cwd: "" });
|
||||
|
||||
const { syncDesktopWindowTitle } = await importFresh();
|
||||
await syncDesktopWindowTitle();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
|
||||
invoke.mockRejectedValue(
|
||||
new Error("Desktop backend transport unavailable"),
|
||||
);
|
||||
await syncDesktopWindowTitle();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProcessContext } from "@/hooks/chat-session/types";
|
||||
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
|
||||
|
||||
export const DEFAULT_DESKTOP_WINDOW_TITLE = "Cline Code";
|
||||
|
||||
export function buildDesktopWindowTitle(version: string | undefined): string {
|
||||
const trimmed = version?.trim();
|
||||
return trimmed
|
||||
? `${DEFAULT_DESKTOP_WINDOW_TITLE} v${trimmed}`
|
||||
: DEFAULT_DESKTOP_WINDOW_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri's window title is static in tauri.conf.json; append the running app
|
||||
* version once the sidecar reports it. No-op outside the Tauri shell (e.g.
|
||||
* sidecar/web dev mode), where there is no native window to retitle.
|
||||
*/
|
||||
export async function syncDesktopWindowTitle(): Promise<void> {
|
||||
if (!isTauriAvailable()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ctx = await desktopClient.invoke<ProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
if (!ctx.appVersion?.trim()) {
|
||||
return;
|
||||
}
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
await getCurrentWindow().setTitle(buildDesktopWindowTitle(ctx.appVersion));
|
||||
} catch {
|
||||
// Keep the default static title if the sidecar or window API is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const NYAN_PET_STORAGE_KEY = "cline-nyan-pet-gif";
|
||||
const NYAN_PET_CHANGE_EVENT = "cline:nyan-pet-changed";
|
||||
|
||||
/** Bundled default pet, served from webview/public. */
|
||||
export const DEFAULT_NYAN_PET_SRC = "/nyancat.gif";
|
||||
|
||||
/**
|
||||
* Upper bound on an uploaded pet. localStorage caps around ~5 MB per origin and
|
||||
* base64 inflates bytes by ~33%, so keep the raw file comfortably under that.
|
||||
*/
|
||||
export const MAX_NYAN_PET_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
/** The custom pet data URL the user uploaded, or null when using the default. */
|
||||
export function readStoredPetGif(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(NYAN_PET_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The image source the pet should render — custom upload or bundled default. */
|
||||
export function getNyanPetSrc(): string {
|
||||
return readStoredPetGif() ?? DEFAULT_NYAN_PET_SRC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (or clear, when passed null) the custom pet and notify live listeners.
|
||||
* Throws if the value exceeds the localStorage quota — callers should validate
|
||||
* size first and surface a friendly error.
|
||||
*/
|
||||
export function setStoredPetGif(dataUrl: string | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (dataUrl) {
|
||||
window.localStorage.setItem(NYAN_PET_STORAGE_KEY, dataUrl);
|
||||
} else {
|
||||
window.localStorage.removeItem(NYAN_PET_STORAGE_KEY);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(NYAN_PET_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to pet changes from this tab (settings edits) or another one
|
||||
* (native `storage` event). Returns a cleanup function.
|
||||
*/
|
||||
export function subscribeNyanPet(listener: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
const handle = () => listener();
|
||||
window.addEventListener(NYAN_PET_CHANGE_EVENT, handle);
|
||||
window.addEventListener("storage", handle);
|
||||
return () => {
|
||||
window.removeEventListener(NYAN_PET_CHANGE_EVENT, handle);
|
||||
window.removeEventListener("storage", handle);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { isTauriAvailable } from "@/lib/desktop-client";
|
||||
|
||||
/** Whether we're running inside the Tauri desktop shell (vs plain web/dev). */
|
||||
export function isTauri(): boolean {
|
||||
return isTauriAvailable();
|
||||
}
|
||||
|
||||
async function invokeTauri<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T | null> {
|
||||
if (!isTauriAvailable()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return await invoke<T>(command, args);
|
||||
} catch (error) {
|
||||
console.error(`pet-window: ${command} failed`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of the Tauri window this document is running in ("main" or "pet"),
|
||||
* or null when not running under Tauri. Used to decide whether to render the
|
||||
* full app or just the floating pet.
|
||||
*/
|
||||
export async function getCurrentWindowLabel(): Promise<string | null> {
|
||||
if (!isTauriAvailable()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
return getCurrentWindow().label;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin an OS-level drag of the pet window (called from the pet's webview). */
|
||||
export const startPetDrag = () => invokeTauri("start_pet_drag");
|
||||
|
||||
/** Show the floating pet window and reassert its always-on-top presence. */
|
||||
export const showPet = () => invokeTauri("show_pet");
|
||||
|
||||
/** Hide the floating pet window. */
|
||||
export const hidePet = () => invokeTauri("hide_pet");
|
||||
|
||||
/** Whether the floating pet window is currently visible. */
|
||||
export const isPetVisible = async (): Promise<boolean> =>
|
||||
(await invokeTauri<boolean>("is_pet_visible")) ?? false;
|
||||
|
||||
/** Reopen (show + focus) the main app window after it was closed/hidden. */
|
||||
export const showMainWindow = () => invokeTauri("show_main_window");
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
isExcludedWorkspacePath,
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
parseWorkspaceSelectionStorage,
|
||||
registerHostHomeDirectory,
|
||||
workspacePathsFromSessions,
|
||||
} from "./workspace-paths";
|
||||
|
||||
@@ -45,6 +48,36 @@ describe("workspace paths", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the first-seen order so earlier groups rank first", () => {
|
||||
expect(
|
||||
mergeWorkspacePaths(["/projects/zulu", "/projects/mike"], [
|
||||
"/projects/alpha",
|
||||
"/projects/zulu/",
|
||||
]),
|
||||
).toEqual(["/projects/zulu", "/projects/mike", "/projects/alpha"]);
|
||||
});
|
||||
|
||||
it("orders the catalog by the most recent session in each workspace", () => {
|
||||
const paths = workspacePathsFromSessions([
|
||||
{ workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" },
|
||||
{
|
||||
workspaceRoot: "/projects/active",
|
||||
startedAt: "2026-02-01T00:00:00Z",
|
||||
endedAt: "2026-02-01T01:00:00Z",
|
||||
},
|
||||
{ workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" },
|
||||
{ workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" },
|
||||
{ workspaceRoot: "/projects/undated" },
|
||||
]);
|
||||
|
||||
expect(paths).toEqual([
|
||||
"/projects/old",
|
||||
"/projects/mid",
|
||||
"/projects/active",
|
||||
"/projects/undated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds the project catalog from every loaded history workspace", () => {
|
||||
const sessions = Array.from({ length: 25 }, (_, index) => ({
|
||||
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
|
||||
@@ -74,4 +107,93 @@ describe("workspace paths", () => {
|
||||
workspaces: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes .cline-internal paths from the workspace catalog", () => {
|
||||
expect(
|
||||
isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExcludedWorkspacePath(
|
||||
"/Users/beatrix/.cline/plugins/_installed/git/github.com/example-plugin",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExcludedWorkspacePath("C:\\Users\\Saoud\\.cline\\worktrees\\abc"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("with a registered host home directory", () => {
|
||||
afterEach(() => {
|
||||
registerHostHomeDirectory("");
|
||||
});
|
||||
|
||||
it("excludes a non-standard home and its Desktop but keeps projects inside them", () => {
|
||||
registerHostHomeDirectory("/srv/homes/bea/");
|
||||
|
||||
expect(isExcludedWorkspacePath("/srv/homes/bea")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/srv/homes/bea/Desktop")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/srv/homes/bea/projects/app")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExcludedWorkspacePath("/srv/homes/beatrix")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches Windows homes case-insensitively", () => {
|
||||
registerHostHomeDirectory("D:\\Homes\\Bea");
|
||||
|
||||
expect(isExcludedWorkspacePath("d:\\homes\\bea\\")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\Desktop")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\cline")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes home and Desktop directories but keeps projects inside them", () => {
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/home/beatrix")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/root")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("C:\\Users\\Saoud")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Desktop")).toBe(true);
|
||||
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix/dev/cline")).toBe(false);
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/my-app")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExcludedWorkspacePath("/home/beatrix/projects")).toBe(false);
|
||||
expect(isExcludedWorkspacePath("/workspace/cline")).toBe(false);
|
||||
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Cline")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters excluded paths out of session-derived workspaces", () => {
|
||||
const paths = workspacePathsFromSessions([
|
||||
{ workspaceRoot: "/projects/app" },
|
||||
{ workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" },
|
||||
{ cwd: "/Users/beatrix/Desktop" },
|
||||
{ cwd: "/Users/beatrix" },
|
||||
{ cwd: "/projects/tool" },
|
||||
]);
|
||||
|
||||
expect(paths).toEqual(["/projects/app", "/projects/tool"]);
|
||||
});
|
||||
|
||||
it("scrubs excluded paths from the stored catalog while keeping the selection", () => {
|
||||
expect(
|
||||
parseWorkspaceSelectionStorage(
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/Users/beatrix/Desktop",
|
||||
workspaces: [
|
||||
"/projects/one",
|
||||
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
|
||||
"/Users/beatrix",
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
lastWorkspace: "/Users/beatrix/Desktop",
|
||||
workspaces: ["/projects/one"],
|
||||
});
|
||||
expect(
|
||||
filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]),
|
||||
).toEqual(["/projects/one"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ export type WorkspaceSelectionStorage = {
|
||||
export type WorkspacePathSource = {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
};
|
||||
|
||||
export function normalizeWorkspacePath(path: string): string {
|
||||
@@ -21,6 +23,11 @@ export function normalizeWorkspacePath(path: string): string {
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes paths across groups, keeping the first spelling seen and the
|
||||
* first-seen position, so callers control the ranking (e.g. session recency)
|
||||
* through argument order.
|
||||
*/
|
||||
export function mergeWorkspacePaths(
|
||||
...pathGroups: ReadonlyArray<readonly string[]>
|
||||
): string[] {
|
||||
@@ -34,15 +41,98 @@ export function mergeWorkspacePaths(
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
|
||||
return [...byNormalizedPath.values()];
|
||||
}
|
||||
|
||||
const POSIX_HOME_OR_DESKTOP_PATTERN =
|
||||
/^(?:\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/Desktop)?$/;
|
||||
const WINDOWS_HOME_OR_DESKTOP_PATTERN =
|
||||
/^[a-z]:[\\/]users[\\/][^\\/]+(?:[\\/]desktop)?$/i;
|
||||
|
||||
let hostHomePath = "";
|
||||
|
||||
/**
|
||||
* The webview bundle has no usable `process.env`, so standard home locations
|
||||
* are matched by the patterns above and the sidecar reports the real host
|
||||
* home directory through `get_process_context` to cover non-standard ones.
|
||||
*/
|
||||
export function registerHostHomeDirectory(path: string): void {
|
||||
hostHomePath = normalizeWorkspacePath(path);
|
||||
}
|
||||
|
||||
function isRegisteredHomeOrDesktop(normalized: string): boolean {
|
||||
if (!hostHomePath) {
|
||||
return false;
|
||||
}
|
||||
if (normalized === hostHomePath) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalized.startsWith(hostHomePath) &&
|
||||
/^[\\/]desktop$/i.test(normalized.slice(hostHomePath.length))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions can run anywhere (Cline-internal worktrees and plugin installs
|
||||
* under `.cline`, or a shell's default cwd like the home or Desktop
|
||||
* directory), but those locations are not projects to offer in the
|
||||
* workspace catalog. The active workspace root is registered separately,
|
||||
* so an explicitly opened directory still shows while selected.
|
||||
*/
|
||||
export function isExcludedWorkspacePath(path: string): boolean {
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.split(/[\\/]/).includes(".cline")) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isRegisteredHomeOrDesktop(normalized) ||
|
||||
POSIX_HOME_OR_DESKTOP_PATTERN.test(normalized) ||
|
||||
WINDOWS_HOME_OR_DESKTOP_PATTERN.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterWorkspacePaths(paths: readonly string[]): string[] {
|
||||
return paths.filter((path) => !isExcludedWorkspacePath(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces with the most recent session activity come first; paths whose
|
||||
* sessions carry no parseable timestamp fall back to alphabetical order at
|
||||
* the end.
|
||||
*/
|
||||
export function workspacePathsFromSessions(
|
||||
sessions: readonly WorkspacePathSource[],
|
||||
): string[] {
|
||||
return mergeWorkspacePaths(
|
||||
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
|
||||
);
|
||||
const lastActivityByPath = new Map<string, number>();
|
||||
for (const session of sessions) {
|
||||
const normalized = normalizeWorkspacePath(
|
||||
session.workspaceRoot || session.cwd || "",
|
||||
);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const activity = Date.parse(session.endedAt ?? session.startedAt ?? "");
|
||||
if (Number.isNaN(activity)) {
|
||||
continue;
|
||||
}
|
||||
const known = lastActivityByPath.get(normalized);
|
||||
if (known === undefined || activity > known) {
|
||||
lastActivityByPath.set(normalized, activity);
|
||||
}
|
||||
}
|
||||
return filterWorkspacePaths(
|
||||
mergeWorkspacePaths(
|
||||
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
|
||||
),
|
||||
).sort((a, b) => {
|
||||
const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0;
|
||||
const bTime = lastActivityByPath.get(normalizeWorkspacePath(b)) ?? 0;
|
||||
return bTime === aTime ? a.localeCompare(b) : bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkspaceSelectionStorage(
|
||||
@@ -67,7 +157,9 @@ export function parseWorkspaceSelectionStorage(
|
||||
: [];
|
||||
return {
|
||||
lastWorkspace,
|
||||
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
workspaces: filterWorkspacePaths(
|
||||
mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
@@ -98,9 +190,9 @@ export function writeWorkspaceSelectionToWindow(
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastWorkspace: value.lastWorkspace.trim(),
|
||||
workspaces: mergeWorkspacePaths(value.workspaces, [
|
||||
value.lastWorkspace,
|
||||
]),
|
||||
workspaces: filterWorkspacePaths(
|
||||
mergeWorkspacePaths(value.workspaces, [value.lastWorkspace]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
@@ -0,0 +1,202 @@
|
||||
# vscode-rollout — A/B loader for the SDK extension rollout
|
||||
|
||||
The VS Code Marketplace has no staged rollouts: publishing a version updates
|
||||
every user. This package lets us ship the **SDK-based extension** (main's
|
||||
`apps/vscode`, "next") to a percentage of users while everyone else keeps
|
||||
running the **legacy extension** (the `legacy-extension` branch), inside a
|
||||
single published VSIX.
|
||||
|
||||
## How it works
|
||||
|
||||
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
|
||||
independently built extension bundles:
|
||||
|
||||
```
|
||||
extension.js ← loader (this package)
|
||||
package.json ← UNION of both bundles' manifests (generated, see below)
|
||||
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
|
||||
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
|
||||
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
|
||||
```
|
||||
|
||||
Per window, the loader:
|
||||
|
||||
1. Reads the cached cohort assignment from its own `globalState` keys —
|
||||
synchronously, never from the network.
|
||||
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
|
||||
palette entries in the union manifest).
|
||||
3. `require()`s exactly one bundle and calls its `activate()` with a
|
||||
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
|
||||
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
|
||||
resolves its own webview build and assets without knowing it was relocated.
|
||||
Storage properties pass through untouched: both bundles share the same
|
||||
`~/.cline/data` + VS Code storage they used as standalone extensions.
|
||||
4. After the selected bundle activates, evaluates the PostHog flags in the
|
||||
background and caches the assignment **for the next window**. Flag changes
|
||||
never flip a live window. A crash fallback skips this refresh so it cannot
|
||||
overwrite the legacy pin.
|
||||
|
||||
If the next bundle throws during activation, the loader disposes whatever it
|
||||
half-registered, pins this VSIX version back to legacy
|
||||
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
|
||||
event, and activates legacy — a crashed rollout self-heals without a
|
||||
marketplace re-publish. A new version gets to try next again.
|
||||
|
||||
## Cohort rules
|
||||
|
||||
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
|
||||
entire remote control surface: each background refresh caches exactly what
|
||||
the flag says for the machine's next window. Dialing the percentage up
|
||||
promotes; dialing it down demotes on the next reload — the emergency lever
|
||||
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
|
||||
created on the SDK bundle are stored as SDK sessions the legacy bundle
|
||||
doesn't list (they reappear on re-promotion — nothing is deleted), and
|
||||
credentials rotated on next may require a re-login on legacy.
|
||||
- **The flag must stay a boolean flag.** The loader only promotes on a
|
||||
literal `true` from `/decide` — a multivariate variant, number, or anything
|
||||
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
|
||||
convert it to multivariate.
|
||||
- The flag is evaluated against the same PostHog distinct id the extension's
|
||||
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
|
||||
so cohort membership is correlatable with telemetry. Flag evaluation is
|
||||
always on (matching `FeatureFlagsService`); the loader's own
|
||||
`extension.rollout.loader_decision` event respects the user's telemetry
|
||||
opt-out and VS Code's global telemetry switch.
|
||||
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
|
||||
user setting (`"auto" | "next" | "legacy"`, editable straight from
|
||||
settings.json) forces a bundle for anyone — users in a pinch, or us
|
||||
debugging — beating the remote assignment both ways. Applies on window
|
||||
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
|
||||
local dev and e2e and beats even the setting. Both are reported as
|
||||
`override` on the loader event so overridden machines don't pollute
|
||||
cohort comparisons.
|
||||
- **Crash pinning is local, not remote.** If the next bundle throws during
|
||||
activation, the loader falls back to legacy in the same window and pins
|
||||
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
|
||||
a new release gets to try next again. This safety net is independent of the
|
||||
flag.
|
||||
|
||||
## The union manifest
|
||||
|
||||
`package.json` contributions are static — VS Code reads them before any code
|
||||
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
|
||||
regenerates it at stitch time from both branches' real manifests:
|
||||
|
||||
- Contributions declared by both bundles pass through untouched.
|
||||
- Menu entries / keybindings declared by only one get `when` AND-ed with
|
||||
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
|
||||
bundle didn't register (and shared buttons that moved position don't render
|
||||
twice).
|
||||
- Commands exclusive to one bundle are hidden from the other cohort's command
|
||||
palette.
|
||||
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
|
||||
**must be identical** in both manifests — they can't be safely gated at
|
||||
runtime, so divergence fails the build. Keep these static contributions in
|
||||
sync between the branches. `engines` may diverge: the union takes the newer
|
||||
requirement (which necessarily satisfies the older one).
|
||||
|
||||
Because the manifest is regenerated from both branches on every build,
|
||||
contribution drift between the branches can't ship silently — it either merges
|
||||
cleanly or the stitch fails.
|
||||
|
||||
## Building locally
|
||||
|
||||
```bash
|
||||
# 1. build both bundles (their own toolchains)
|
||||
cd apps/vscode && bun run package # next
|
||||
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
|
||||
|
||||
# 2. build the loader + stitch + package
|
||||
cd apps/vscode-rollout
|
||||
bun run build # dev build; CI uses build:production with the PostHog key
|
||||
node scripts/stitch.mjs \
|
||||
--next ../vscode --legacy <legacy worktree>/apps/vscode \
|
||||
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
|
||||
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
|
||||
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
|
||||
```
|
||||
|
||||
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
|
||||
legacy packaging workflows. This workflow supplies only the existing PostHog
|
||||
project-key inputs; it does not declare a SendGrid credential. Identify the
|
||||
exact matching string in production staging output before changing or
|
||||
broadening the exemption.
|
||||
|
||||
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
|
||||
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
|
||||
|
||||
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
|
||||
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
|
||||
|
||||
## Nightly channel
|
||||
|
||||
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
|
||||
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
|
||||
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
|
||||
nightly identity — the same mutation the standalone nightly always applied
|
||||
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
|
||||
truth), so nightly can be installed alongside stable:
|
||||
|
||||
| | stable | nightly |
|
||||
|---|---|---|
|
||||
| manifest `name` | `claude-dev` | `cline-nightly` |
|
||||
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
|
||||
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
|
||||
|
||||
The loader derives the namespace from its own `packageJSON.name` at runtime
|
||||
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
|
||||
manifest's name — no build flags involved. Nightly builds also show a
|
||||
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
|
||||
|
||||
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
|
||||
the installable `.vsix` without publishing or tagging. The publish job is
|
||||
intentionally restricted to `main` by both the workflow and the
|
||||
`PublishNightly` environment's deployment-branch policy.
|
||||
|
||||
### Telemetry events
|
||||
|
||||
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
|
||||
activated bundle's own telemetry via its `reportRolloutActivation` export;
|
||||
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
|
||||
actual bundle, fallback flag, error details on fallback. Every other event
|
||||
from a rollout build carries `extension_variant` as a common property.
|
||||
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
|
||||
loader-side metadata the bundle event can't know — override source, launch
|
||||
cadence, loader version, `extension_name` (nightly vs stable) — and the only
|
||||
signal when BOTH bundles fail (`double_failure: true`).
|
||||
|
||||
## Rollout runbook
|
||||
|
||||
Until the stable combined VSIX ships, the flag governs **nightly installs
|
||||
only** — dialing it is safe for production users and is the lever for moving
|
||||
nightly dogfooders onto next.
|
||||
|
||||
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
|
||||
plain boolean release flag with a percentage rollout, starting at **0%**.
|
||||
(There is deliberately no kill-switch flag — the assignment is two-way, so
|
||||
0% *is* the kill switch.)
|
||||
2. Publish the combined VSIX (version above every previously published one).
|
||||
With the rollout at 0% this release is behaviorally identical to legacy for
|
||||
everyone — it only validates the loader plumbing in the wild. Watch
|
||||
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
|
||||
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
|
||||
each machine's next window reload after its flag refresh, so propagation
|
||||
speed is bounded by how often people reload windows — watch the
|
||||
`ms_since_last_activation` distribution on loader events to see real
|
||||
uptake lag before deciding the next step, and compare cohorts by the
|
||||
`bundle` property.
|
||||
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
|
||||
on their next reload). Demoted machines keep settings and creds; tasks
|
||||
created on the SDK bundle reappear when re-promoted. Ship the fix as a
|
||||
higher version, then dial back up. Machines whose next bundle *crashed*
|
||||
are additionally version-pinned to legacy locally, independent of the flag.
|
||||
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
|
||||
extension build and delete this package.
|
||||
|
||||
## Version numbering
|
||||
|
||||
The combined VSIX owns the marketplace version line and must always exceed the
|
||||
last version published from either branch (legacy stable was 4.0.x → start at
|
||||
4.1.0). The bundles' own `package.json` versions ride along inside their
|
||||
subdirectories for provenance; the loader reports the combined version as
|
||||
`loader_version`.
|
||||
@@ -0,0 +1,27 @@
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const production = process.argv.includes("--production");
|
||||
|
||||
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
|
||||
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
|
||||
// loader skips all PostHog calls (everyone stays on legacy).
|
||||
const define = {};
|
||||
if (process.env.TELEMETRY_SERVICE_API_KEY) {
|
||||
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
|
||||
process.env.TELEMETRY_SERVICE_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ["src/extension.ts"],
|
||||
bundle: true,
|
||||
outfile: "dist/extension.js",
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
target: "node18",
|
||||
external: ["vscode"],
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
define,
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@cline/vscode-rollout",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test src scripts",
|
||||
"stitch": "node scripts/stitch.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/vscode": "1.84.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Generate the combined VSIX's package.json as the UNION of the two bundles'
|
||||
* manifests, regenerated from both branches' actual package.json files at
|
||||
* stitch time so contribution drift between branches can't ship silently.
|
||||
*
|
||||
* Rules:
|
||||
* - Identity/top-level fields come from the next (main) manifest.
|
||||
* - `main` points at the loader; `version` comes from the release input.
|
||||
* - commands / menus / keybindings / activationEvents / icons are unioned.
|
||||
* Menu entries and keybindings present in only ONE manifest get their
|
||||
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
|
||||
* loader before activation; prefix follows the manifest identity — see
|
||||
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
|
||||
* its bundle doesn't register — and shared buttons that moved position
|
||||
* don't show up twice. Commands exclusive to one bundle are likewise hidden
|
||||
* from the other cohort's command palette.
|
||||
* - views / viewsContainers / configuration / engines MUST be
|
||||
* identical in both manifests — they can't be safely gated at runtime, so
|
||||
* divergence is a hard error.
|
||||
*
|
||||
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
|
||||
*/
|
||||
|
||||
import { deepStrictEqual } from "node:assert";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
export function generateManifest(nextPkg, legacyPkg, version) {
|
||||
for (const field of ["name", "publisher", "main"]) {
|
||||
if (nextPkg[field] !== legacyPkg[field]) {
|
||||
throw new Error(
|
||||
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const field of ["views", "viewsContainers", "configuration"]) {
|
||||
try {
|
||||
deepStrictEqual(
|
||||
nextPkg.contributes?.[field],
|
||||
legacyPkg.contributes?.[field],
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
|
||||
|
||||
assertWalkthroughsCompatible(
|
||||
nextPkg.contributes?.walkthroughs,
|
||||
legacyPkg.contributes?.walkthroughs,
|
||||
);
|
||||
|
||||
// The nightly packaging rewrites the whole `cline.*` ID namespace to
|
||||
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
|
||||
// injected setting must follow the manifest's identity. Keep in sync with
|
||||
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
|
||||
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
|
||||
const nextGate = `${prefix}.sdkBundle`;
|
||||
const legacyGate = `!${nextGate}`;
|
||||
|
||||
const nc = nextPkg.contributes ?? {};
|
||||
const lc = legacyPkg.contributes ?? {};
|
||||
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
|
||||
hideExclusiveCommandsFromPalette(
|
||||
menus,
|
||||
nc.commands,
|
||||
lc.commands,
|
||||
nextGate,
|
||||
legacyGate,
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
name: nextPkg.name,
|
||||
displayName: nextPkg.displayName,
|
||||
description: nextPkg.description,
|
||||
version,
|
||||
icon: nextPkg.icon,
|
||||
engines,
|
||||
author: nextPkg.author,
|
||||
license: nextPkg.license,
|
||||
publisher: nextPkg.publisher,
|
||||
repository: nextPkg.repository,
|
||||
homepage: nextPkg.homepage,
|
||||
categories: nextPkg.categories,
|
||||
keywords: nextPkg.keywords,
|
||||
activationEvents: unionPrimitive(
|
||||
nextPkg.activationEvents,
|
||||
legacyPkg.activationEvents,
|
||||
),
|
||||
main: "./extension.js",
|
||||
contributes: {
|
||||
viewsContainers: nc.viewsContainers,
|
||||
views: nc.views,
|
||||
commands: unionBy(
|
||||
[...(nc.commands ?? []), ...(lc.commands ?? [])],
|
||||
(c) => c.command,
|
||||
),
|
||||
keybindings: unionGated(
|
||||
nc.keybindings,
|
||||
lc.keybindings,
|
||||
nextGate,
|
||||
legacyGate,
|
||||
),
|
||||
menus,
|
||||
icons: unionIcons(nc.icons, lc.icons),
|
||||
configuration: injectLoaderConfiguration(nc.configuration, prefix),
|
||||
walkthroughs: nc.walkthroughs,
|
||||
},
|
||||
scripts: {},
|
||||
};
|
||||
|
||||
assertSuperset(manifest, nextPkg, "next", nextGate);
|
||||
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader's own user-visible escape hatch, keyed by the manifest identity.
|
||||
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
|
||||
* settingSection/SETTING_BUNDLE_OVERRIDE — keep the key and values in sync).
|
||||
* Injected after the configuration-equality invariant so it can't mask real
|
||||
* drift between the bundles.
|
||||
*/
|
||||
function loaderSettings(prefix) {
|
||||
return {
|
||||
[`${prefix}.rollout.bundleOverride`]: {
|
||||
type: "string",
|
||||
enum: ["auto", "next", "legacy"],
|
||||
enumDescriptions: [
|
||||
"Follow the remote rollout assignment.",
|
||||
"Force the new (SDK-based) extension.",
|
||||
"Force the previous (legacy) extension.",
|
||||
],
|
||||
default: "auto",
|
||||
scope: "application",
|
||||
markdownDescription:
|
||||
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function injectLoaderConfiguration(configuration, prefix) {
|
||||
const properties = { ...(configuration?.properties ?? {}) };
|
||||
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
|
||||
if (properties[key]) {
|
||||
throw new Error(
|
||||
`bundle manifests must not declare loader-owned setting ${key}`,
|
||||
);
|
||||
}
|
||||
properties[key] = schema;
|
||||
}
|
||||
return { title: "Cline", ...(configuration ?? {}), properties };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
|
||||
* always comes from the next checkout — so requiring byte-identical manifests
|
||||
* here would brick releases over copy tweaks while protecting nothing. Only
|
||||
* STRUCTURE must match (walkthrough/step ids, media paths, completion events —
|
||||
* the parts code and the manifest reference); when titles/descriptions
|
||||
* diverge, next's copy ships for everyone and the build says so.
|
||||
*/
|
||||
function assertWalkthroughsCompatible(next = [], legacy = []) {
|
||||
const structure = (walkthroughs) =>
|
||||
walkthroughs.map((walkthrough) => ({
|
||||
id: walkthrough.id,
|
||||
steps: (walkthrough.steps ?? []).map((step) => ({
|
||||
id: step.id,
|
||||
media: step.media,
|
||||
completionEvents: step.completionEvents,
|
||||
when: step.when,
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
deepStrictEqual(structure(next), structure(legacy));
|
||||
} catch {
|
||||
throw new Error(
|
||||
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
|
||||
);
|
||||
}
|
||||
try {
|
||||
deepStrictEqual(next, legacy);
|
||||
} catch {
|
||||
console.warn(
|
||||
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines can safely diverge in ONE direction: the union requires whichever
|
||||
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
|
||||
* older requirement too. (main routinely bumps the VS Code engine ahead of the
|
||||
* legacy branch — an equality assertion here would brick every combined build
|
||||
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
|
||||
* guessing.
|
||||
*/
|
||||
function unionEngines(nextEngines = {}, legacyEngines = {}) {
|
||||
const union = {};
|
||||
for (const key of new Set([
|
||||
...Object.keys(nextEngines),
|
||||
...Object.keys(legacyEngines),
|
||||
])) {
|
||||
const a = nextEngines[key];
|
||||
const b = legacyEngines[key];
|
||||
if (a === undefined || b === undefined || a === b) {
|
||||
union[key] = a ?? b;
|
||||
continue;
|
||||
}
|
||||
const minimum = (range) => {
|
||||
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
|
||||
return match?.[1];
|
||||
};
|
||||
const [minA, minB] = [minimum(a), minimum(b)];
|
||||
if (!minA || !minB) {
|
||||
throw new Error(
|
||||
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
|
||||
);
|
||||
}
|
||||
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
|
||||
console.warn(
|
||||
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
|
||||
);
|
||||
}
|
||||
return union;
|
||||
}
|
||||
|
||||
/** Compare dotted numeric versions. */
|
||||
function compareDotted(a, b) {
|
||||
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function unionPrimitive(a = [], b = []) {
|
||||
return [...new Set([...a, ...b])];
|
||||
}
|
||||
|
||||
/** Union keeping first occurrence per key (next wins on shared ids). */
|
||||
function unionBy(items, keyFn) {
|
||||
const seen = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, item);
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
function sortKeysDeep(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortKeysDeep);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, sortKeysDeep(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(sortKeysDeep(value));
|
||||
}
|
||||
|
||||
function gateWhen(entry, gate) {
|
||||
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Union two entry lists (menu entries or keybindings): entries declared by
|
||||
* both bundles pass through untouched; entries declared by only one get their
|
||||
* `when` AND-ed with that bundle's cohort gate.
|
||||
*/
|
||||
function unionGated(
|
||||
nextEntries = [],
|
||||
legacyEntries = [],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
) {
|
||||
const nextSet = new Set(nextEntries.map(stableJson));
|
||||
const legacySet = new Set(legacyEntries.map(stableJson));
|
||||
const entries = [];
|
||||
for (const entry of nextEntries) {
|
||||
entries.push(
|
||||
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
|
||||
);
|
||||
}
|
||||
for (const entry of legacyEntries) {
|
||||
if (!nextSet.has(stableJson(entry))) {
|
||||
entries.push(gateWhen(entry, legacyGate));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
|
||||
const menus = {};
|
||||
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
menus[location] = unionGated(
|
||||
a[location],
|
||||
b[location],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
);
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A command declared by only one bundle would surface in the other cohort's
|
||||
* command palette with no registered handler ("command not found" on run).
|
||||
* Hide it there unless that bundle's own manifest already constrains it.
|
||||
*/
|
||||
function hideExclusiveCommandsFromPalette(
|
||||
menus,
|
||||
nextCommands = [],
|
||||
legacyCommands = [],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
) {
|
||||
const nextIds = new Set(nextCommands.map((c) => c.command));
|
||||
const legacyIds = new Set(legacyCommands.map((c) => c.command));
|
||||
const palette = menus.commandPalette ?? (menus.commandPalette = []);
|
||||
const alreadyListed = new Set(palette.map((e) => e.command));
|
||||
for (const id of nextIds) {
|
||||
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
|
||||
palette.push({ command: id, when: nextGate });
|
||||
}
|
||||
}
|
||||
for (const id of legacyIds) {
|
||||
if (!nextIds.has(id) && !alreadyListed.has(id)) {
|
||||
palette.push({ command: id, when: legacyGate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unionIcons(a = {}, b = {}) {
|
||||
const icons = { ...b, ...a };
|
||||
for (const id of Object.keys(icons)) {
|
||||
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
|
||||
throw new Error(
|
||||
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every command/keybinding/menu entry/activation event a bundle declares must
|
||||
* survive the union, either verbatim or with its `when` AND-ed with that
|
||||
* bundle's cohort gate.
|
||||
*/
|
||||
function assertSuperset(manifest, sourcePkg, label, gate) {
|
||||
const missing = [];
|
||||
const commandIds = new Set(
|
||||
manifest.contributes.commands.map((c) => c.command),
|
||||
);
|
||||
for (const cmd of sourcePkg.contributes?.commands ?? []) {
|
||||
if (!commandIds.has(cmd.command)) {
|
||||
missing.push(`command ${cmd.command}`);
|
||||
}
|
||||
}
|
||||
for (const event of sourcePkg.activationEvents ?? []) {
|
||||
if (!manifest.activationEvents.includes(event)) {
|
||||
missing.push(`activationEvent ${event}`);
|
||||
}
|
||||
}
|
||||
const presentOrGated = (unionEntries, entry) => {
|
||||
const set = new Set((unionEntries ?? []).map(stableJson));
|
||||
return (
|
||||
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
|
||||
);
|
||||
};
|
||||
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
|
||||
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
|
||||
missing.push(`keybinding ${kb.command}`);
|
||||
}
|
||||
}
|
||||
for (const [location, entries] of Object.entries(
|
||||
sourcePkg.contributes?.menus ?? {},
|
||||
)) {
|
||||
for (const entry of entries) {
|
||||
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
|
||||
missing.push(
|
||||
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const { next, legacy, version, out } = parseArgs(process.argv);
|
||||
if (!next || !legacy || !version) {
|
||||
console.error(
|
||||
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = generateManifest(
|
||||
JSON.parse(readFileSync(next, "utf8")),
|
||||
JSON.parse(readFileSync(legacy, "utf8")),
|
||||
version,
|
||||
);
|
||||
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
|
||||
if (out) {
|
||||
writeFileSync(out, json);
|
||||
console.log(`wrote ${out}`);
|
||||
} else {
|
||||
process.stdout.write(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { generateManifest } from "./gen-manifest.mjs";
|
||||
|
||||
const shared = {
|
||||
name: "claude-dev",
|
||||
publisher: "saoudrizwan",
|
||||
main: "./dist/extension.js",
|
||||
engines: { vscode: "^1.84.0" },
|
||||
displayName: "Cline",
|
||||
};
|
||||
|
||||
function pkg(overrides) {
|
||||
return {
|
||||
...shared,
|
||||
activationEvents: ["onStartupFinished"],
|
||||
contributes: {
|
||||
viewsContainers: {
|
||||
activitybar: [{ id: "c", title: "Cline", icon: "assets/icon.svg" }],
|
||||
},
|
||||
views: { c: [{ type: "webview", id: "claude-dev.SidebarProvider" }] },
|
||||
commands: [],
|
||||
keybindings: [],
|
||||
menus: {},
|
||||
icons: {},
|
||||
...overrides.contributes,
|
||||
},
|
||||
...Object.fromEntries(
|
||||
Object.entries(overrides).filter(([k]) => k !== "contributes"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateManifest", () => {
|
||||
it("unions commands, menus, keybindings and activation events", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.a", title: "A" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: { "view/title": [{ command: "cline.a", when: "x" }] },
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
activationEvents: ["onStartupFinished", "workspaceContains:evals.env"],
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.b", title: "B" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: {
|
||||
"view/title": [{ command: "cline.b", when: "y" }],
|
||||
"comments/commentThread/title": [{ command: "cline.b" }],
|
||||
},
|
||||
keybindings: [{ command: "cline.b", key: "ctrl+k" }],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(next, legacy, "4.1.0");
|
||||
expect(manifest.version).toBe("4.1.0");
|
||||
expect(manifest.main).toBe("./extension.js");
|
||||
expect(manifest.contributes.commands.map((c) => c.command).sort()).toEqual([
|
||||
"cline.a",
|
||||
"cline.b",
|
||||
"cline.shared",
|
||||
]);
|
||||
expect(manifest.contributes.menus["view/title"]).toHaveLength(2);
|
||||
expect(
|
||||
manifest.contributes.menus["comments/commentThread/title"],
|
||||
).toHaveLength(1);
|
||||
expect(manifest.contributes.keybindings).toHaveLength(1);
|
||||
expect(manifest.activationEvents).toContain("workspaceContains:evals.env");
|
||||
});
|
||||
|
||||
it("gates cohort-exclusive menu entries and keybindings on the context key", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.a", title: "A" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: {
|
||||
"view/title": [
|
||||
{ command: "cline.a", when: "x" },
|
||||
{ command: "cline.shared", when: "v" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.b", title: "B" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: {
|
||||
"view/title": [
|
||||
{ command: "cline.b", when: "y" },
|
||||
{ command: "cline.shared", when: "v" },
|
||||
],
|
||||
},
|
||||
keybindings: [{ command: "cline.b", key: "ctrl+k", when: "focus" }],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(next, legacy, "4.1.0");
|
||||
const viewTitle = manifest.contributes.menus["view/title"];
|
||||
expect(viewTitle.find((e) => e.command === "cline.a").when).toBe(
|
||||
"(x) && cline.sdkBundle",
|
||||
);
|
||||
expect(viewTitle.find((e) => e.command === "cline.b").when).toBe(
|
||||
"(y) && !cline.sdkBundle",
|
||||
);
|
||||
expect(viewTitle.find((e) => e.command === "cline.shared").when).toBe("v");
|
||||
expect(manifest.contributes.keybindings[0].when).toBe(
|
||||
"(focus) && !cline.sdkBundle",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides cohort-exclusive commands from the other cohort's palette", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.nextOnly", title: "N" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.legacyOnly", title: "L" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
|
||||
.commandPalette;
|
||||
expect(palette).toContainEqual({
|
||||
command: "cline.nextOnly",
|
||||
when: "cline.sdkBundle",
|
||||
});
|
||||
expect(palette).toContainEqual({
|
||||
command: "cline.legacyOnly",
|
||||
when: "!cline.sdkBundle",
|
||||
});
|
||||
expect(palette.find((e) => e.command === "cline.shared")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves commands alone when a bundle already declares a palette entry for them", () => {
|
||||
const next = pkg({
|
||||
contributes: { commands: [{ command: "cline.shared", title: "S" }] },
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: "cline.hidden", title: "H" },
|
||||
{ command: "cline.shared", title: "S" },
|
||||
],
|
||||
menus: { commandPalette: [{ command: "cline.hidden", when: "false" }] },
|
||||
},
|
||||
});
|
||||
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
|
||||
.commandPalette;
|
||||
expect(palette.filter((e) => e.command === "cline.hidden")).toEqual([
|
||||
{ command: "cline.hidden", when: "(false) && !cline.sdkBundle" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes structurally identical menu entries", () => {
|
||||
const entry = { command: "cline.a", when: "view == cline" };
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
commands: [{ command: "cline.a", title: "A" }],
|
||||
menus: { "view/title": [entry] },
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
commands: [{ command: "cline.a", title: "A" }],
|
||||
menus: { "view/title": [{ ...entry }] },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
generateManifest(next, legacy, "1.0.0").contributes.menus["view/title"],
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects diverged views/viewsContainers", () => {
|
||||
const next = pkg({});
|
||||
const legacy = pkg({
|
||||
contributes: { views: { c: [{ type: "webview", id: "other" }] } },
|
||||
});
|
||||
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/views/);
|
||||
});
|
||||
|
||||
it("rejects structurally diverged walkthroughs", () => {
|
||||
const walkthrough = (stepId, media) => ({
|
||||
contributes: {
|
||||
walkthroughs: [
|
||||
{
|
||||
id: "ClineWalkthrough",
|
||||
title: "Meet Cline",
|
||||
steps: [{ id: stepId, title: "Start here", media }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
generateManifest(
|
||||
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
|
||||
pkg(walkthrough("hello", { markdown: "walkthrough/step1.md" })),
|
||||
"1.0.0",
|
||||
),
|
||||
).toThrow(/walkthroughs diverged structurally/);
|
||||
expect(() =>
|
||||
generateManifest(
|
||||
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
|
||||
pkg(walkthrough("welcome", { markdown: "walkthrough/other.md" })),
|
||||
"1.0.0",
|
||||
),
|
||||
).toThrow(/walkthroughs diverged structurally/);
|
||||
});
|
||||
|
||||
it("tolerates copy-only walkthrough divergence, shipping next's text", () => {
|
||||
const walkthrough = (description) => ({
|
||||
contributes: {
|
||||
walkthroughs: [
|
||||
{
|
||||
id: "ClineWalkthrough",
|
||||
title: "Meet Cline",
|
||||
steps: [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Start here",
|
||||
description,
|
||||
media: { markdown: "walkthrough/step1.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(
|
||||
pkg(walkthrough("Connect via MCP.")),
|
||||
pkg(walkthrough("Discover the MCP Marketplace.")),
|
||||
"1.0.0",
|
||||
);
|
||||
expect(manifest.contributes.walkthroughs[0].steps[0].description).toBe(
|
||||
"Connect via MCP.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects diverged configuration", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: {
|
||||
"cline.enabled": { type: "boolean", default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: {
|
||||
"cline.enabled": { type: "boolean", default: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(
|
||||
/contributes\.configuration diverged/,
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the loader-owned bundleOverride setting into the union", () => {
|
||||
const manifest = generateManifest(pkg({}), pkg({}), "4.1.0");
|
||||
const prop =
|
||||
manifest.contributes.configuration.properties[
|
||||
"cline.rollout.bundleOverride"
|
||||
];
|
||||
expect(prop).toBeDefined();
|
||||
expect(prop.enum).toEqual(["auto", "next", "legacy"]);
|
||||
expect(prop.default).toBe("auto");
|
||||
expect(prop.scope).toBe("application");
|
||||
});
|
||||
|
||||
it("rejects bundles that declare the loader-owned setting themselves", () => {
|
||||
const withClash = {
|
||||
contributes: {
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: { "cline.rollout.bundleOverride": { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
generateManifest(pkg(withClash), pkg(withClash), "4.1.0"),
|
||||
).toThrow(/loader-owned setting/);
|
||||
});
|
||||
|
||||
it("derives gates and the injected setting from the nightly identity", () => {
|
||||
const nightly = (overrides) => ({
|
||||
...pkg(overrides),
|
||||
name: "cline-nightly",
|
||||
displayName: "Cline (Nightly)",
|
||||
});
|
||||
const next = nightly({
|
||||
contributes: {
|
||||
commands: [{ command: "cline-nightly.nextOnly", title: "N" }],
|
||||
menus: {
|
||||
"view/title": [{ command: "cline-nightly.nextOnly", when: "x" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = nightly({
|
||||
contributes: {
|
||||
commands: [{ command: "cline-nightly.legacyOnly", title: "L" }],
|
||||
keybindings: [{ command: "cline-nightly.legacyOnly", key: "ctrl+k" }],
|
||||
},
|
||||
});
|
||||
const manifest = generateManifest(next, legacy, "4.0.1752600000");
|
||||
expect(manifest.name).toBe("cline-nightly");
|
||||
expect(manifest.contributes.menus["view/title"][0].when).toBe(
|
||||
"(x) && cline-nightly.sdkBundle",
|
||||
);
|
||||
expect(manifest.contributes.keybindings[0].when).toBe(
|
||||
"!cline-nightly.sdkBundle",
|
||||
);
|
||||
expect(manifest.contributes.menus.commandPalette).toContainEqual({
|
||||
command: "cline-nightly.legacyOnly",
|
||||
when: "!cline-nightly.sdkBundle",
|
||||
});
|
||||
const properties = manifest.contributes.configuration.properties;
|
||||
expect(properties["cline-nightly.rollout.bundleOverride"]).toBeDefined();
|
||||
expect(properties["cline.rollout.bundleOverride"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unions diverged engines to the newer requirement (either direction)", () => {
|
||||
const olderLegacy = { ...pkg({}), engines: { vscode: "^1.74.0" } };
|
||||
expect(generateManifest(pkg({}), olderLegacy, "1.0.0").engines).toEqual({
|
||||
vscode: "^1.84.0",
|
||||
});
|
||||
const newerLegacy = { ...pkg({}), engines: { vscode: "^1.101.0" } };
|
||||
expect(generateManifest(pkg({}), newerLegacy, "1.0.0").engines).toEqual({
|
||||
vscode: "^1.101.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects diverged engines it cannot compare", () => {
|
||||
const legacy = { ...pkg({}), engines: { vscode: ">=1.84.0 <2.0.0" } };
|
||||
expect(() => generateManifest(pkg({}), legacy, "1.0.0")).toThrow(
|
||||
/uncomparable/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects conflicting icon definitions", () => {
|
||||
const next = pkg({
|
||||
contributes: {
|
||||
icons: {
|
||||
"cline-logo": {
|
||||
description: "d",
|
||||
default: { fontPath: "a.woff", fontCharacter: "\\E900" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacy = pkg({
|
||||
contributes: {
|
||||
icons: {
|
||||
"cline-logo": {
|
||||
description: "d",
|
||||
default: { fontPath: "b.woff", fontCharacter: "\\E900" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/icons/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Rewrite an apps/vscode package.json to the nightly identity, in place.
|
||||
*
|
||||
* Reproduces updatePackageJson() from apps/vscode/scripts/publish-nightly.mjs
|
||||
* (the same script exists on BOTH main and legacy-extension — those copies are
|
||||
* the source of truth for the mutation; if they change, change this too):
|
||||
* - textual rewrites: "claude-dev" -> "cline-nightly" everywhere, and every
|
||||
* `"cline.` ID prefix -> `"cline-nightly.` (commands, settings, view IDs,
|
||||
* when-clauses that START with the key — mid-string references like
|
||||
* `config.cline.x` are NOT rewritten, same as the standalone nightly)
|
||||
* - name / displayName / activity bar title / version
|
||||
*
|
||||
* Differences from publish-nightly.mjs, on purpose:
|
||||
* - the version is an explicit ARGUMENT, not computed here: the combined
|
||||
* VSIX applies ONE version to the next bundle, the legacy bundle, and the
|
||||
* union manifest, so gen-manifest's identity-equality assertions hold.
|
||||
* - no backup/restore, README swapping, or workspace-self-link reconciling:
|
||||
* this runs against a disposable CI checkout, BEFORE the bundle build and
|
||||
* never followed by vsce in that checkout (vsce only runs in the stitched
|
||||
* staging dir with --no-dependencies).
|
||||
*
|
||||
* Run it AFTER dependency install (the workspace self-link resolution keys off
|
||||
* the original package name) and BEFORE the bundle's package build.
|
||||
*
|
||||
* Usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const NIGHTLY_NAME = "cline-nightly";
|
||||
export const NIGHTLY_DISPLAY_NAME = "Cline (Nightly)";
|
||||
|
||||
export function nightlifyPackageJson(rawContent, version) {
|
||||
if (!version) {
|
||||
throw new Error("version is required");
|
||||
}
|
||||
const content = rawContent
|
||||
.replaceAll("claude-dev", NIGHTLY_NAME)
|
||||
.replaceAll('"cline.', `"${NIGHTLY_NAME}.`);
|
||||
const pkg = JSON.parse(content);
|
||||
|
||||
pkg.name = NIGHTLY_NAME;
|
||||
pkg.displayName = NIGHTLY_DISPLAY_NAME;
|
||||
pkg.version = version;
|
||||
// publish-nightly.mjs assigns `.title` on the activitybar value directly,
|
||||
// which is a silent no-op on the real manifest (activitybar is an ARRAY —
|
||||
// JSON.stringify drops non-index properties). Retitle the actual entries.
|
||||
const activitybar = pkg.contributes?.viewsContainers?.activitybar;
|
||||
for (const container of Array.isArray(activitybar) ? activitybar : []) {
|
||||
container.title = NIGHTLY_DISPLAY_NAME;
|
||||
}
|
||||
|
||||
return `${JSON.stringify(pkg, null, "\t")}\n`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${path.resolve(process.argv[1])}`
|
||||
) {
|
||||
const { dir, version } = parseArgs(process.argv);
|
||||
if (!dir || !version) {
|
||||
console.error(
|
||||
"usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
const before = readFileSync(packageJsonPath, "utf8");
|
||||
const beforeName = JSON.parse(before).name;
|
||||
writeFileSync(packageJsonPath, nightlifyPackageJson(before, version));
|
||||
console.log(
|
||||
`nightlified ${packageJsonPath}: ${beforeName} -> ${NIGHTLY_NAME}@${version}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { nightlifyPackageJson } from "./nightlify.mjs";
|
||||
|
||||
const fixture = {
|
||||
name: "claude-dev",
|
||||
displayName: "Cline",
|
||||
publisher: "saoudrizwan",
|
||||
version: "4.0.0",
|
||||
main: "./dist/extension.js",
|
||||
contributes: {
|
||||
viewsContainers: {
|
||||
activitybar: [
|
||||
{
|
||||
id: "claude-dev-ActivityBar",
|
||||
title: "Cline",
|
||||
icon: "assets/icon.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
views: {
|
||||
"claude-dev-ActivityBar": [
|
||||
{ type: "webview", id: "claude-dev.SidebarProvider" },
|
||||
],
|
||||
},
|
||||
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
|
||||
keybindings: [{ command: "cline.addToChat", key: "ctrl+'" }],
|
||||
menus: {
|
||||
"view/title": [
|
||||
{
|
||||
command: "cline.plusButtonClicked",
|
||||
when: "view == claude-dev.SidebarProvider",
|
||||
},
|
||||
// Mid-string references are NOT rewritten — a known limitation
|
||||
// shared with the standalone nightly's publish-nightly.mjs.
|
||||
{ command: "cline.addToChat", when: "config.cline.enableExtras" },
|
||||
],
|
||||
},
|
||||
configuration: {
|
||||
title: "Cline",
|
||||
properties: { "cline.enableExtras": { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("nightlifyPackageJson", () => {
|
||||
const pkg = JSON.parse(
|
||||
nightlifyPackageJson(JSON.stringify(fixture, null, "\t"), "4.0.1752600000"),
|
||||
);
|
||||
|
||||
it("sets the nightly identity and the supplied version", () => {
|
||||
expect(pkg.name).toBe("cline-nightly");
|
||||
expect(pkg.displayName).toBe("Cline (Nightly)");
|
||||
expect(pkg.version).toBe("4.0.1752600000");
|
||||
expect(pkg.publisher).toBe("saoudrizwan");
|
||||
});
|
||||
|
||||
it("rewrites claude-dev IDs and the cline.* namespace", () => {
|
||||
expect(pkg.contributes.viewsContainers.activitybar[0].id).toBe(
|
||||
"cline-nightly-ActivityBar",
|
||||
);
|
||||
expect(pkg.contributes.viewsContainers.activitybar[0].title).toBe(
|
||||
"Cline (Nightly)",
|
||||
);
|
||||
expect(Object.keys(pkg.contributes.views)).toEqual([
|
||||
"cline-nightly-ActivityBar",
|
||||
]);
|
||||
expect(pkg.contributes.views["cline-nightly-ActivityBar"][0].id).toBe(
|
||||
"cline-nightly.SidebarProvider",
|
||||
);
|
||||
expect(pkg.contributes.commands[0].command).toBe(
|
||||
"cline-nightly.plusButtonClicked",
|
||||
);
|
||||
expect(pkg.contributes.keybindings[0].command).toBe(
|
||||
"cline-nightly.addToChat",
|
||||
);
|
||||
expect(Object.keys(pkg.contributes.configuration.properties)).toEqual([
|
||||
"cline-nightly.enableExtras",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rewrites when-clauses that start with a rewritten ID, but not mid-string references", () => {
|
||||
const [gated, midString] = pkg.contributes.menus["view/title"];
|
||||
expect(gated.when).toBe("view == cline-nightly.SidebarProvider");
|
||||
// Documented limitation: `config.cline.` does not match the `"cline.`
|
||||
// pattern, so it survives unrewritten (matches publish-nightly.mjs).
|
||||
expect(midString.when).toBe("config.cline.enableExtras");
|
||||
});
|
||||
|
||||
it("requires a version", () => {
|
||||
expect(() => nightlifyPackageJson("{}", undefined)).toThrow(/version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Stamp the combined VSIX's version into a bundle checkout's package.json,
|
||||
* in place, BEFORE that bundle builds.
|
||||
*
|
||||
* Why: the union manifest's version (what the Marketplace and auto-update
|
||||
* see) is supplied at stitch time, but each bundle's runtime reads its OWN
|
||||
* package.json — the About tab and every telemetry event's extension_version
|
||||
* come from there. Without this stamp the stable combined VSIX would report
|
||||
* three different versions (union input, main's base version, legacy's base
|
||||
* version) depending on where you look, which turns user bug reports into
|
||||
* archaeology. The nightly path gets the same alignment via nightlify.mjs
|
||||
* (which also rewrites identity); this script is the identity-preserving
|
||||
* version-only equivalent for the stable channel.
|
||||
*
|
||||
* Usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function setPackageVersion(rawContent, version) {
|
||||
if (!version) {
|
||||
throw new Error("version is required");
|
||||
}
|
||||
const pkg = JSON.parse(rawContent);
|
||||
pkg.version = version;
|
||||
return `${JSON.stringify(pkg, null, "\t")}\n`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${path.resolve(process.argv[1])}`
|
||||
) {
|
||||
const { dir, version } = parseArgs(process.argv);
|
||||
if (!dir || !version) {
|
||||
console.error(
|
||||
"usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
const before = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
|
||||
writeFileSync(
|
||||
packageJsonPath,
|
||||
setPackageVersion(readFileSync(packageJsonPath, "utf8"), version),
|
||||
);
|
||||
console.log(`set ${packageJsonPath} version: ${before} -> ${version}`);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { setPackageVersion } from "./set-version.mjs";
|
||||
|
||||
const fixture = JSON.stringify(
|
||||
{
|
||||
name: "claude-dev",
|
||||
displayName: "Cline",
|
||||
publisher: "saoudrizwan",
|
||||
version: "4.0.0",
|
||||
contributes: {
|
||||
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
|
||||
describe("setPackageVersion", () => {
|
||||
it("stamps the version and touches nothing else", () => {
|
||||
const pkg = JSON.parse(setPackageVersion(fixture, "4.1.0"));
|
||||
expect(pkg.version).toBe("4.1.0");
|
||||
expect(pkg.name).toBe("claude-dev");
|
||||
expect(pkg.displayName).toBe("Cline");
|
||||
expect(pkg.publisher).toBe("saoudrizwan");
|
||||
expect(pkg.contributes.commands[0].command).toBe(
|
||||
"cline.plusButtonClicked",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a version", () => {
|
||||
expect(() => setPackageVersion(fixture, undefined)).toThrow(/version/);
|
||||
expect(() => setPackageVersion(fixture, "")).toThrow(/version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Node-level smoke test for the built loader against a staged VSIX directory.
|
||||
* No real VS Code: `vscode` is stubbed just enough for the loader itself, and
|
||||
* the staging dir's next/legacy bundles are swapped for tiny recorders. Verifies
|
||||
* the loader's end-to-end behavior in a real require() environment:
|
||||
* 1. default (no cached cohort) -> activates legacy
|
||||
* 2. cached cohort "next" -> activates next, scoped context paths
|
||||
* 3. the flag refresh caches a TWO-WAY assignment for the next window
|
||||
* (rollout on promotes, rollout off demotes a cached "next")
|
||||
* 4. CLINE_BUNDLE_OVERRIDE / the cline.rollout.bundleOverride setting
|
||||
* force a bundle in either direction
|
||||
* 5. next activation throws -> disposes partial registrations, falls
|
||||
* back to legacy, pins version, and
|
||||
* skips the cohort refresh
|
||||
* 6. the activated bundle's reportRolloutActivation export receives the
|
||||
* authoritative attempted/actual/fallback record (and its absence is
|
||||
* tolerated); the loader's own loader_decision capture fires exactly
|
||||
* once per window
|
||||
* 7. the nightly identity (manifest name cline-nightly) switches the
|
||||
* setting section + context key namespace and shows the status bar
|
||||
* bundle indicator
|
||||
* 8. both bundles throwing surfaces the failure and captures a
|
||||
* double_failure loader event
|
||||
*
|
||||
* Usage: node smoke-loader.mjs <staging-dir>
|
||||
* Copies the staging dir to a temp sandbox; the input is never modified.
|
||||
*/
|
||||
import assert from "node:assert";
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import Module from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const staging = process.argv[2];
|
||||
if (!staging) {
|
||||
console.error("usage: node smoke-loader.mjs <staging-dir>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- vscode API stub (only what the loader touches) -------------------------
|
||||
const executedCommands = [];
|
||||
const statusBarItems = [];
|
||||
function makeVscodeStub(
|
||||
sandbox,
|
||||
settings = {},
|
||||
{ telemetryEnabled = false } = {},
|
||||
) {
|
||||
return {
|
||||
Uri: {
|
||||
file: (fsPath) => ({ fsPath, path: fsPath, scheme: "file" }),
|
||||
joinPath: (base, ...segments) => {
|
||||
const fsPath = path.join(base.fsPath, ...segments);
|
||||
return { fsPath, path: fsPath, scheme: "file" };
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
executeCommand: async (command, ...args) => {
|
||||
executedCommands.push([command, ...args]);
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
getConfiguration: (section) => ({
|
||||
get: (key) => settings[`${section}.${key}`],
|
||||
}),
|
||||
},
|
||||
window: {
|
||||
createStatusBarItem: () => {
|
||||
const item = {
|
||||
text: "",
|
||||
tooltip: "",
|
||||
shown: false,
|
||||
show() {
|
||||
this.shown = true;
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
statusBarItems.push(item);
|
||||
return item;
|
||||
},
|
||||
},
|
||||
StatusBarAlignment: { Left: 1, Right: 2 },
|
||||
env: { machineId: "smoke-machine", isTelemetryEnabled: telemetryEnabled },
|
||||
version: "0.0.0-smoke",
|
||||
_sandbox: sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(sandbox, globalStateSeed = {}, packageJSON = {}) {
|
||||
const state = new Map(Object.entries(globalStateSeed));
|
||||
return {
|
||||
extensionUri: { fsPath: sandbox, path: sandbox, scheme: "file" },
|
||||
extensionPath: sandbox,
|
||||
extension: { packageJSON: { version: "4.1.0-smoke", ...packageJSON } },
|
||||
subscriptions: [],
|
||||
globalState: {
|
||||
get: (key) => state.get(key),
|
||||
update: async (key, value) => void state.set(key, value),
|
||||
_dump: () => Object.fromEntries(state),
|
||||
},
|
||||
asAbsolutePath: (rel) => path.join(sandbox, rel),
|
||||
};
|
||||
}
|
||||
|
||||
/** PostHog /capture/ POSTs recorded by a scenario's fetch stub, parsed. */
|
||||
function captureCalls(fetchCalls) {
|
||||
return fetchCalls
|
||||
.filter(([url]) => String(url).includes("/capture/"))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
}
|
||||
|
||||
function captureEvents(fetchCalls, event) {
|
||||
return captureCalls(fetchCalls).filter((capture) => capture.event === event);
|
||||
}
|
||||
|
||||
function loaderDecisionCaptures(fetchCalls) {
|
||||
return captureEvents(fetchCalls, "extension.rollout.loader_decision");
|
||||
}
|
||||
|
||||
function featureFlagCalledCaptures(fetchCalls) {
|
||||
return captureEvents(fetchCalls, "$feature_flag_called");
|
||||
}
|
||||
|
||||
function decideCalls(fetchCalls) {
|
||||
return fetchCalls.filter(([url]) => String(url).includes("/decide"));
|
||||
}
|
||||
|
||||
function flagResponse(flags = { rollout: false }) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
featureFlags: {
|
||||
"ext-sdk-bundle-rollout": flags.rollout,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFlagFetch(flags) {
|
||||
const calls = [];
|
||||
const fetch = async (...args) => {
|
||||
calls.push(args);
|
||||
return flagResponse(flags);
|
||||
};
|
||||
return { calls, fetch };
|
||||
}
|
||||
|
||||
function makeDeferredFlagFetch(flags) {
|
||||
const calls = [];
|
||||
let resolveResponse;
|
||||
let markStarted;
|
||||
const started = new Promise((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const fetch = (...args) => {
|
||||
calls.push(args);
|
||||
markStarted();
|
||||
return new Promise((resolve) => {
|
||||
resolveResponse = () => resolve(flagResponse(flags));
|
||||
});
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
fetch,
|
||||
started,
|
||||
resolve: () => resolveResponse?.(),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate, message, timeoutMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
assert.fail(message);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
// ---- sandbox setup -----------------------------------------------------------
|
||||
function makeSandbox({
|
||||
nextThrows = false,
|
||||
legacyThrows = false,
|
||||
omitReportExport = false,
|
||||
} = {}) {
|
||||
const sandbox = mkdtempSync(path.join(tmpdir(), "cline-ab-smoke-"));
|
||||
cpSync(
|
||||
path.join(staging, "extension.js"),
|
||||
path.join(sandbox, "extension.js"),
|
||||
);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
mkdirSync(path.join(sandbox, bundle, "dist"), { recursive: true });
|
||||
const throws =
|
||||
(bundle === "next" && nextThrows) ||
|
||||
(bundle === "legacy" && legacyThrows);
|
||||
const throwLine = throws
|
||||
? `await global.__smoke.beforeNextFailure?.();\n\t\tctx.subscriptions.push({ dispose() { global.__smoke.disposed.push("${bundle}") } });\n\t\tthrow new Error("smoke: ${bundle} activation exploded");`
|
||||
: "";
|
||||
// Mirrors the reportRolloutActivation export both real bundles gained in
|
||||
// their rollout-telemetry PRs; recorded so scenarios can assert the
|
||||
// authoritative attempted/actual/fallback record.
|
||||
const reportExport = omitReportExport
|
||||
? ""
|
||||
: `exports.reportRolloutActivation = async (input) => { global.__smoke.reports.push({ reporter: "${bundle}", attemptedBundle: input.attemptedBundle, actualBundle: input.actualBundle, fallback: input.fallback, hasError: input.error !== undefined }); };`;
|
||||
writeFileSync(
|
||||
path.join(sandbox, bundle, "dist", "extension.js"),
|
||||
`exports.activate = async (ctx) => {
|
||||
${throwLine}
|
||||
global.__smoke.activated.push({ bundle: "${bundle}", extensionPath: ctx.extensionPath, asAbs: ctx.asAbsolutePath("webview-ui/build") });
|
||||
return { bundle: "${bundle}" };
|
||||
};
|
||||
exports.deactivate = () => { global.__smoke.deactivated.push("${bundle}"); };
|
||||
${reportExport}`,
|
||||
);
|
||||
}
|
||||
mkdirSync(path.join(sandbox, "data"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(sandbox, "data", "globalState.json"),
|
||||
JSON.stringify({ "cline.generatedMachineId": "smoke-machine" }),
|
||||
);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
name,
|
||||
{
|
||||
seed = {},
|
||||
env = {},
|
||||
settings = {},
|
||||
nextThrows = false,
|
||||
legacyThrows = false,
|
||||
omitReportExport = false,
|
||||
telemetryEnabled = false,
|
||||
contextPackageJSON = {},
|
||||
expectFailure = false,
|
||||
fetchController = makeFlagFetch(),
|
||||
beforeNextFailure,
|
||||
expectRefresh = true,
|
||||
},
|
||||
checks,
|
||||
afterDeactivateChecks = async () => {},
|
||||
) {
|
||||
const sandbox = makeSandbox({ nextThrows, legacyThrows, omitReportExport });
|
||||
global.__smoke = {
|
||||
activated: [],
|
||||
deactivated: [],
|
||||
disposed: [],
|
||||
reports: [],
|
||||
beforeNextFailure,
|
||||
};
|
||||
executedCommands.length = 0;
|
||||
statusBarItems.length = 0;
|
||||
|
||||
const previousEnv = {};
|
||||
const scenarioEnv = {
|
||||
CLINE_DIR: sandbox,
|
||||
// A dev build leaves this lookup dynamic; production builds inline the
|
||||
// real PostHog key. Either way, the smoke must exercise refreshCohort.
|
||||
TELEMETRY_SERVICE_API_KEY: "smoke-posthog-project-key",
|
||||
...env,
|
||||
};
|
||||
for (const [key, value] of Object.entries(scenarioEnv)) {
|
||||
previousEnv[key] = process.env[key];
|
||||
process.env[key] = value;
|
||||
}
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = fetchController.fetch;
|
||||
const originalResolve = Module._resolveFilename;
|
||||
Module._resolveFilename = function (request, ...rest) {
|
||||
if (request === "vscode") {
|
||||
return "vscode";
|
||||
}
|
||||
return originalResolve.call(this, request, ...rest);
|
||||
};
|
||||
require.cache.vscode = {
|
||||
id: "vscode",
|
||||
filename: "vscode",
|
||||
loaded: true,
|
||||
exports: makeVscodeStub(sandbox, settings, { telemetryEnabled }),
|
||||
};
|
||||
|
||||
try {
|
||||
const loaderPath = path.join(sandbox, "extension.js");
|
||||
delete require.cache[loaderPath];
|
||||
const loader = require(loaderPath);
|
||||
const context = makeContext(sandbox, seed, contextPackageJSON);
|
||||
let api;
|
||||
let activationError;
|
||||
try {
|
||||
api = await loader.activate(context);
|
||||
} catch (error) {
|
||||
activationError = error;
|
||||
}
|
||||
if (expectFailure) {
|
||||
assert.ok(activationError, `${name} should have failed to activate`);
|
||||
} else if (activationError) {
|
||||
throw activationError;
|
||||
}
|
||||
if (expectRefresh) {
|
||||
await waitFor(
|
||||
() => decideCalls(fetchController.calls).length > 0,
|
||||
`${name} did not refresh its cohort after activation`,
|
||||
);
|
||||
await flushAsyncWork();
|
||||
assert.equal(
|
||||
decideCalls(fetchController.calls).length,
|
||||
1,
|
||||
`${name} should refresh its cohort exactly once`,
|
||||
);
|
||||
}
|
||||
await checks({
|
||||
context,
|
||||
api,
|
||||
activationError,
|
||||
sandbox,
|
||||
fetchCalls: fetchController.calls,
|
||||
});
|
||||
await loader.deactivate();
|
||||
await afterDeactivateChecks({ context, api, sandbox });
|
||||
console.log(`PASS ${name}`);
|
||||
} finally {
|
||||
Module._resolveFilename = originalResolve;
|
||||
delete require.cache.vscode;
|
||||
if (originalFetch === undefined) {
|
||||
delete global.fetch;
|
||||
} else {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const require = Module.createRequire(import.meta.url);
|
||||
|
||||
await runScenario("default cohort -> legacy", {}, async ({ api, sandbox }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.equal(
|
||||
global.__smoke.activated[0].extensionPath,
|
||||
path.join(sandbox, "legacy"),
|
||||
);
|
||||
assert.deepEqual(executedCommands[0], [
|
||||
"setContext",
|
||||
"cline.sdkBundle",
|
||||
false,
|
||||
]);
|
||||
assert.deepEqual(global.__smoke.deactivated, []);
|
||||
// The activated bundle received the authoritative activation record.
|
||||
assert.deepEqual(global.__smoke.reports, [
|
||||
{
|
||||
reporter: "legacy",
|
||||
attemptedBundle: "legacy",
|
||||
actualBundle: "legacy",
|
||||
fallback: false,
|
||||
hasError: false,
|
||||
},
|
||||
]);
|
||||
// Stable identity: no nightly status bar indicator.
|
||||
assert.equal(statusBarItems.length, 0);
|
||||
});
|
||||
|
||||
await runScenario(
|
||||
"cached next -> next with scoped paths",
|
||||
{ seed: { "cline.rollout.bundle": "next" } },
|
||||
async ({ api, sandbox }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
const activation = global.__smoke.activated[0];
|
||||
assert.equal(activation.extensionPath, path.join(sandbox, "next"));
|
||||
assert.equal(
|
||||
activation.asAbs,
|
||||
path.join(sandbox, "next", "webview-ui", "build"),
|
||||
);
|
||||
assert.deepEqual(executedCommands[0], [
|
||||
"setContext",
|
||||
"cline.sdkBundle",
|
||||
true,
|
||||
]);
|
||||
assert.deepEqual(global.__smoke.reports, [
|
||||
{
|
||||
reporter: "next",
|
||||
attemptedBundle: "next",
|
||||
actualBundle: "next",
|
||||
fallback: false,
|
||||
hasError: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"rollout flag on promotes for the NEXT window only",
|
||||
{
|
||||
fetchController: makeFlagFetch({ rollout: true }),
|
||||
},
|
||||
async ({ context, api, fetchCalls }) => {
|
||||
// This window already decided legacy from the (empty) cache; the refresh
|
||||
// promotes the NEXT window.
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "next");
|
||||
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
|
||||
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag_response, true);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"rollout flag off demotes a cached next for the NEXT window (two-way)",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
fetchController: makeFlagFetch({ rollout: false }),
|
||||
},
|
||||
async ({ context, api, fetchCalls }) => {
|
||||
// This window already ran next; dialing the flag down moves the machine
|
||||
// back to legacy on its next reload.
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
|
||||
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
|
||||
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
|
||||
assert.equal(featureFlagCalled.event, "$feature_flag_called");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
|
||||
assert.equal(featureFlagCalled.properties.$feature_flag_response, false);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"env override forces next",
|
||||
{ env: { CLINE_BUNDLE_OVERRIDE: "next" } },
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"user setting overrides to legacy despite cached next",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
settings: { "cline.rollout.bundleOverride": "legacy" },
|
||||
},
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"user setting overrides to next despite a cached legacy assignment",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "legacy" },
|
||||
settings: { "cline.rollout.bundleOverride": "next" },
|
||||
},
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
},
|
||||
);
|
||||
|
||||
const failedNextRefresh = makeDeferredFlagFetch({ rollout: true });
|
||||
await runScenario(
|
||||
"next activation failure falls back to legacy",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
nextThrows: true,
|
||||
fetchController: failedNextRefresh,
|
||||
expectRefresh: false,
|
||||
beforeNextFailure: () =>
|
||||
Promise.race([
|
||||
failedNextRefresh.started,
|
||||
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||
]),
|
||||
},
|
||||
async ({ context, api, fetchCalls }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.deepEqual(
|
||||
global.__smoke.disposed,
|
||||
["next"],
|
||||
"partial registrations disposed",
|
||||
);
|
||||
const state = context.globalState._dump();
|
||||
assert.equal(state["cline.rollout.bundle"], "legacy");
|
||||
assert.equal(
|
||||
state["cline.rollout.nextActivationFailedVersion"],
|
||||
"4.1.0-smoke",
|
||||
);
|
||||
assert.equal(
|
||||
context.subscriptions.length,
|
||||
0,
|
||||
"failed bundle's subscriptions removed",
|
||||
);
|
||||
// setContext flipped back for the legacy UI
|
||||
assert.deepEqual(executedCommands.at(-1), [
|
||||
"setContext",
|
||||
"cline.sdkBundle",
|
||||
false,
|
||||
]);
|
||||
// The LEGACY bundle (the one whose telemetry pipeline is alive) received
|
||||
// the authoritative fallback record; the dead next bundle reported nothing.
|
||||
assert.deepEqual(global.__smoke.reports, [
|
||||
{
|
||||
reporter: "legacy",
|
||||
attemptedBundle: "next",
|
||||
actualBundle: "legacy",
|
||||
fallback: true,
|
||||
hasError: true,
|
||||
},
|
||||
]);
|
||||
// Keep the fetch stub installed long enough for an incorrectly delayed
|
||||
// refresh to reach the network boundary before asserting its absence.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// Settle a refresh if the loader incorrectly launched one. With the old
|
||||
// ordering it would now promote COHORT_STATE_KEY back to next.
|
||||
if (decideCalls(fetchCalls).length > 0) {
|
||||
failedNextRefresh.resolve();
|
||||
await flushAsyncWork();
|
||||
}
|
||||
assert.equal(
|
||||
decideCalls(fetchCalls).length,
|
||||
0,
|
||||
"crash fallback must not refresh the failed cohort",
|
||||
);
|
||||
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"loader_decision capture carries the loader-side metadata",
|
||||
{
|
||||
env: { CLINE_BUNDLE_OVERRIDE: "next" },
|
||||
telemetryEnabled: true,
|
||||
contextPackageJSON: { name: "claude-dev" },
|
||||
},
|
||||
async ({ fetchCalls }) => {
|
||||
await waitFor(
|
||||
() => loaderDecisionCaptures(fetchCalls).length > 0,
|
||||
"loader_decision capture never reached the network",
|
||||
);
|
||||
const captures = loaderDecisionCaptures(fetchCalls);
|
||||
assert.equal(captures.length, 1);
|
||||
const [capture] = captures;
|
||||
assert.equal(capture.event, "extension.rollout.loader_decision");
|
||||
assert.equal(capture.properties.bundle, "next");
|
||||
assert.equal(capture.properties.attempted_bundle, "next");
|
||||
assert.equal(capture.properties.fallback, false);
|
||||
assert.equal(capture.properties.override, "env");
|
||||
assert.equal(capture.properties.loader_version, "4.1.0-smoke");
|
||||
assert.equal(capture.properties.extension_name, "claude-dev");
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"crash fallback captures exactly one loader_decision event",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
nextThrows: true,
|
||||
telemetryEnabled: true,
|
||||
expectRefresh: false,
|
||||
},
|
||||
async ({ fetchCalls }) => {
|
||||
await waitFor(
|
||||
() => loaderDecisionCaptures(fetchCalls).length > 0,
|
||||
"fallback loader_decision capture never reached the network",
|
||||
);
|
||||
// Give an incorrect second capture (the pre-fix fallback:false event from
|
||||
// the recursive legacy success) time to reach the network before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const captures = loaderDecisionCaptures(fetchCalls);
|
||||
assert.equal(
|
||||
captures.length,
|
||||
1,
|
||||
"fallback must emit exactly ONE loader event (regression: duplicate fallback:false event)",
|
||||
);
|
||||
const [capture] = captures;
|
||||
assert.equal(capture.event, "extension.rollout.loader_decision");
|
||||
assert.equal(capture.properties.bundle, "legacy");
|
||||
assert.equal(capture.properties.attempted_bundle, "next");
|
||||
assert.equal(capture.properties.fallback, true);
|
||||
assert.match(capture.properties.error_message, /next activation exploded/);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"a bundle without the reportRolloutActivation export still activates",
|
||||
{ omitReportExport: true },
|
||||
async ({ api }) => {
|
||||
assert.deepEqual(api, { bundle: "legacy" });
|
||||
assert.deepEqual(global.__smoke.reports, []);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"nightly identity: namespaced setting + context key, status bar indicator",
|
||||
{
|
||||
contextPackageJSON: { name: "cline-nightly" },
|
||||
settings: { "cline-nightly.rollout.bundleOverride": "next" },
|
||||
},
|
||||
async ({ api, context }) => {
|
||||
assert.deepEqual(api, { bundle: "next" });
|
||||
assert.deepEqual(executedCommands[0], [
|
||||
"setContext",
|
||||
"cline-nightly.sdkBundle",
|
||||
true,
|
||||
]);
|
||||
assert.equal(statusBarItems.length, 1);
|
||||
const [item] = statusBarItems;
|
||||
assert.equal(item.shown, true);
|
||||
assert.equal(item.text, "Cline: Next");
|
||||
assert.match(item.tooltip, /bundleOverride setting/);
|
||||
assert.ok(
|
||||
context.subscriptions.includes(item),
|
||||
"indicator must be disposed with the extension",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"double failure: both bundles throw, loader reports and rethrows",
|
||||
{
|
||||
seed: { "cline.rollout.bundle": "next" },
|
||||
nextThrows: true,
|
||||
legacyThrows: true,
|
||||
telemetryEnabled: true,
|
||||
expectFailure: true,
|
||||
expectRefresh: false,
|
||||
},
|
||||
async ({ activationError, fetchCalls }) => {
|
||||
assert.match(String(activationError), /legacy activation exploded/);
|
||||
assert.deepEqual(
|
||||
global.__smoke.reports,
|
||||
[],
|
||||
"no bundle survived to report the authoritative event",
|
||||
);
|
||||
await waitFor(
|
||||
() => loaderDecisionCaptures(fetchCalls).length >= 2,
|
||||
"double failure should capture the fallback AND the double_failure events",
|
||||
);
|
||||
const captures = loaderDecisionCaptures(fetchCalls);
|
||||
assert.equal(captures.length, 2);
|
||||
for (const capture of captures) {
|
||||
assert.equal(capture.event, "extension.rollout.loader_decision");
|
||||
assert.equal(capture.properties.fallback, true);
|
||||
}
|
||||
const doubleFailure = captures.find(
|
||||
(c) => c.properties.double_failure === true,
|
||||
);
|
||||
assert.ok(doubleFailure, "one capture must be flagged double_failure");
|
||||
assert.equal(doubleFailure.properties.attempted_bundle, "next");
|
||||
assert.match(
|
||||
doubleFailure.properties.error_message,
|
||||
/legacy activation exploded/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
"deactivate delegates to active bundle",
|
||||
{},
|
||||
async () => {},
|
||||
async () => {
|
||||
assert.deepEqual(global.__smoke.deactivated, ["legacy"]);
|
||||
},
|
||||
);
|
||||
|
||||
console.log("\nall loader smoke scenarios passed");
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Assemble the combined (loader + next + legacy) VSIX staging directory.
|
||||
*
|
||||
* Layout produced:
|
||||
* <out>/
|
||||
* extension.js loader bundle (this package's dist/extension.js)
|
||||
* package.json union manifest (gen-manifest.mjs)
|
||||
* README.md next's marketplace README
|
||||
* LICENSE, CHANGELOG.md, assets/, walkthrough/ from next (manifest-referenced, VSIX-root-relative)
|
||||
* next/ SDK extension payload (dist/, webview-ui/build/, assets/, package.json)
|
||||
* legacy/ legacy extension payload (dist/, webview-ui/build/, assets/,
|
||||
* node_modules/@vscode/codicons/dist/, package.json)
|
||||
*
|
||||
* Each bundle resolves its own resources under its subdirectory because the
|
||||
* loader hands it an ExtensionContext whose extensionUri/extensionPath point
|
||||
* there (see src/scoped-context.ts). Manifest-referenced resources (icons,
|
||||
* walkthrough media, codicon font declared in contributes.icons) resolve from
|
||||
* the VSIX root, where the stitcher places next's copies.
|
||||
*
|
||||
* Usage:
|
||||
* node stitch.mjs --next <apps/vscode dir, built> --legacy <apps/vscode dir, built> \
|
||||
* --loader <dist/extension.js> --version <x.y.z> --out <staging dir>
|
||||
*/
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { generateManifest } from "./gen-manifest.mjs";
|
||||
|
||||
// Legacy's webview loads codicon.css straight from node_modules (see its
|
||||
// WebviewProvider); next bundles the font into its webview build but its own
|
||||
// .vscodeignore still re-includes the codicons dist, so mirror that here.
|
||||
const BUNDLE_PAYLOAD = {
|
||||
next: [
|
||||
"dist",
|
||||
"webview-ui/build",
|
||||
"assets",
|
||||
"package.json",
|
||||
"node_modules/@vscode/codicons/dist",
|
||||
],
|
||||
legacy: [
|
||||
"dist",
|
||||
"webview-ui/build",
|
||||
"assets",
|
||||
"package.json",
|
||||
"node_modules/@vscode/codicons/dist",
|
||||
],
|
||||
};
|
||||
|
||||
/** VSIX-root files, all taken from the next checkout (manifest fields come from next too). */
|
||||
const ROOT_PAYLOAD = ["LICENSE", "CHANGELOG.md", "assets", "walkthrough"];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function copyInto(sourceRoot, relPaths, destRoot, { optional = [] } = {}) {
|
||||
for (const rel of relPaths) {
|
||||
const source = path.join(sourceRoot, rel);
|
||||
if (!existsSync(source)) {
|
||||
if (optional.includes(rel)) {
|
||||
console.warn(` skip (missing, optional): ${rel}`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`required payload missing: ${source} — did the bundle build run?`,
|
||||
);
|
||||
}
|
||||
cpSync(source, path.join(destRoot, rel), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
console.log(` + ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function stitch({ next, legacy, loader, version, out }) {
|
||||
for (const [name, value] of Object.entries({
|
||||
next,
|
||||
legacy,
|
||||
loader,
|
||||
version,
|
||||
out,
|
||||
})) {
|
||||
if (!value) {
|
||||
throw new Error(`--${name} is required`);
|
||||
}
|
||||
}
|
||||
// Refuse to stage from an unbuilt tree early, with a clear message.
|
||||
for (const [name, root] of [
|
||||
["next", next],
|
||||
["legacy", legacy],
|
||||
]) {
|
||||
if (!existsSync(path.join(root, "dist", "extension.js"))) {
|
||||
throw new Error(
|
||||
`${name} bundle not built: ${root}/dist/extension.js missing`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!existsSync(path.join(root, "webview-ui", "build", "assets", "index.js"))
|
||||
) {
|
||||
throw new Error(
|
||||
`${name} webview not built: ${root}/webview-ui/build/assets/index.js missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
console.log("root payload (from next):");
|
||||
copyInto(next, ROOT_PAYLOAD, out, {
|
||||
optional: ["CHANGELOG.md", "walkthrough"],
|
||||
});
|
||||
cpSync(loader, path.join(out, "extension.js"));
|
||||
console.log(" + extension.js (loader)");
|
||||
|
||||
const readme = path.join(next, "README.marketplace.md");
|
||||
cpSync(
|
||||
existsSync(readme) ? readme : path.join(next, "README.md"),
|
||||
path.join(out, "README.md"),
|
||||
);
|
||||
console.log(" + README.md");
|
||||
|
||||
for (const [bundle, payload] of Object.entries(BUNDLE_PAYLOAD)) {
|
||||
const sourceRoot = bundle === "next" ? next : legacy;
|
||||
console.log(`${bundle} payload:`);
|
||||
copyInto(sourceRoot, payload, path.join(out, bundle), {
|
||||
optional: ["walkthrough"],
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = generateManifest(
|
||||
JSON.parse(readFileSync(path.join(next, "package.json"), "utf8")),
|
||||
JSON.parse(readFileSync(path.join(legacy, "package.json"), "utf8")),
|
||||
version,
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(out, "package.json"),
|
||||
`${JSON.stringify(manifest, null, "\t")}\n`,
|
||||
);
|
||||
console.log(" + package.json (union manifest)");
|
||||
|
||||
// vsce packages everything in the staging dir; only strip sourcemaps and
|
||||
// junk. The codicons files under legacy/node_modules must survive, so no
|
||||
// blanket node_modules ignore here — staging only ever contains what this
|
||||
// script copied.
|
||||
writeFileSync(
|
||||
path.join(out, ".vscodeignore"),
|
||||
["**/*.map", "**/.DS_Store", ""].join("\n"),
|
||||
);
|
||||
|
||||
console.log(`\nstaged ${out} (version ${version})`);
|
||||
// Keep the scanner exemption category-scoped to match the standalone bundle
|
||||
// workflows; see the README for its scope and verification notes.
|
||||
console.log(
|
||||
`package it with:\n cd ${out} && vsce package --no-dependencies --allow-package-secrets sendgrid`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${path.resolve(process.argv[1])}`
|
||||
) {
|
||||
try {
|
||||
stitch(parseArgs(process.argv));
|
||||
} catch (error) {
|
||||
console.error(`stitch failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
bundleContextKey,
|
||||
decideBundle,
|
||||
decisionOverrideSource,
|
||||
idPrefix,
|
||||
parseRolloutAssignment,
|
||||
ROLLOUT_FLAG,
|
||||
settingSection,
|
||||
} from "./cohort";
|
||||
|
||||
const base = {
|
||||
envOverride: undefined,
|
||||
settingOverride: undefined,
|
||||
cached: undefined,
|
||||
previousFailure: false,
|
||||
};
|
||||
|
||||
describe("decideBundle", () => {
|
||||
it("defaults to legacy with no cached assignment", () => {
|
||||
expect(decideBundle(base)).toBe("legacy");
|
||||
});
|
||||
|
||||
it("uses the cached assignment", () => {
|
||||
expect(decideBundle({ ...base, cached: "next" })).toBe("next");
|
||||
expect(decideBundle({ ...base, cached: "legacy" })).toBe("legacy");
|
||||
});
|
||||
|
||||
it("treats unknown cached values as legacy", () => {
|
||||
expect(decideBundle({ ...base, cached: "garbage" })).toBe("legacy");
|
||||
});
|
||||
|
||||
it("a previous activation failure on this version forces legacy", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, cached: "next", previousFailure: true }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("env override beats everything, including a previous failure", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, envOverride: "next", previousFailure: true }),
|
||||
).toBe("next");
|
||||
expect(
|
||||
decideBundle({ ...base, envOverride: "legacy", cached: "next" }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("user setting overrides in both directions", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, settingOverride: "next", previousFailure: true }),
|
||||
).toBe("next");
|
||||
expect(
|
||||
decideBundle({ ...base, settingOverride: "legacy", cached: "next" }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("env override beats the user setting", () => {
|
||||
expect(
|
||||
decideBundle({ ...base, envOverride: "legacy", settingOverride: "next" }),
|
||||
).toBe("legacy");
|
||||
});
|
||||
|
||||
it("ignores invalid and 'auto' overrides", () => {
|
||||
expect(decideBundle({ ...base, envOverride: "beta", cached: "next" })).toBe(
|
||||
"next",
|
||||
);
|
||||
expect(
|
||||
decideBundle({ ...base, settingOverride: "auto", cached: "next" }),
|
||||
).toBe("next");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decisionOverrideSource", () => {
|
||||
it("reports which override was active", () => {
|
||||
expect(decisionOverrideSource(base)).toBeUndefined();
|
||||
expect(decisionOverrideSource({ ...base, settingOverride: "next" })).toBe(
|
||||
"setting",
|
||||
);
|
||||
expect(
|
||||
decisionOverrideSource({
|
||||
...base,
|
||||
envOverride: "legacy",
|
||||
settingOverride: "next",
|
||||
}),
|
||||
).toBe("env");
|
||||
expect(
|
||||
decisionOverrideSource({ ...base, settingOverride: "auto" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRolloutAssignment", () => {
|
||||
it("promotes only on a literal boolean true", () => {
|
||||
expect(
|
||||
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: true } }),
|
||||
).toBe("next");
|
||||
});
|
||||
|
||||
it("is two-way: anything else resolves to legacy (fail-safe)", () => {
|
||||
// false = dialed out of the cohort; the rest = mis-configured flag.
|
||||
for (const value of ["test", "control", 1, 0.5, {}, false, undefined]) {
|
||||
expect(
|
||||
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: value } }),
|
||||
).toBe("legacy");
|
||||
}
|
||||
// Flag deleted / not created yet: nobody promoted.
|
||||
expect(parseRolloutAssignment({ featureFlags: {} })).toBe("legacy");
|
||||
});
|
||||
|
||||
it("returns undefined for malformed responses (cache left untouched)", () => {
|
||||
expect(parseRolloutAssignment(undefined)).toBeUndefined();
|
||||
expect(parseRolloutAssignment({})).toBeUndefined();
|
||||
expect(parseRolloutAssignment({ featureFlags: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity prefix", () => {
|
||||
it("maps the nightly manifest name to the cline-nightly namespace", () => {
|
||||
expect(idPrefix("cline-nightly")).toBe("cline-nightly");
|
||||
});
|
||||
|
||||
it("maps everything else (stable claude-dev, unknown, missing) to cline", () => {
|
||||
expect(idPrefix("claude-dev")).toBe("cline");
|
||||
expect(idPrefix("some-fork")).toBe("cline");
|
||||
expect(idPrefix(undefined)).toBe("cline");
|
||||
});
|
||||
|
||||
it("derives the setting section and context key from the prefix", () => {
|
||||
expect(settingSection("cline")).toBe("cline.rollout");
|
||||
expect(settingSection("cline-nightly")).toBe("cline-nightly.rollout");
|
||||
expect(bundleContextKey("cline")).toBe("cline.sdkBundle");
|
||||
expect(bundleContextKey("cline-nightly")).toBe("cline-nightly.sdkBundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
export type Bundle = "next" | "legacy";
|
||||
|
||||
/**
|
||||
* The combined VSIX ships under two identities: the stable extension
|
||||
* (manifest name "claude-dev", contribution IDs under "cline.*") and the
|
||||
* nightly (name "cline-nightly", IDs under "cline-nightly.*" — the nightly
|
||||
* packaging rewrites every `"cline.` prefix in the manifest, see
|
||||
* scripts/nightlify.mjs and apps/vscode/scripts/publish-nightly.mjs). Anything
|
||||
* the loader reads from or feeds back into the manifest namespace — the
|
||||
* bundleOverride setting and the sdkBundle context key — must use the prefix
|
||||
* matching the installed identity. scripts/gen-manifest.mjs derives the same
|
||||
* prefix when generating the union manifest; keep them in sync.
|
||||
*/
|
||||
export const NIGHTLY_EXTENSION_NAME = "cline-nightly";
|
||||
export type IdPrefix = "cline" | "cline-nightly";
|
||||
|
||||
export function idPrefix(extensionName: string | undefined): IdPrefix {
|
||||
return extensionName === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline";
|
||||
}
|
||||
|
||||
/** Settings section holding the bundleOverride escape hatch. */
|
||||
export function settingSection(prefix: IdPrefix): string {
|
||||
return `${prefix}.rollout`;
|
||||
}
|
||||
|
||||
/** Context key gating per-cohort menus/keybindings in the union manifest. */
|
||||
export function bundleContextKey(prefix: IdPrefix): string {
|
||||
return `${prefix}.sdkBundle`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader-owned VS Code memento keys. Never touched by either bundle. These
|
||||
* deliberately stay un-prefixed by identity: globalState is already scoped to
|
||||
* the extension ID, so a stable and a nightly install can never collide.
|
||||
*/
|
||||
export const COHORT_STATE_KEY = "cline.rollout.bundle";
|
||||
/** Version of the combined VSIX whose `next` bundle failed to activate, if any. */
|
||||
export const FAILED_VERSION_STATE_KEY =
|
||||
"cline.rollout.nextActivationFailedVersion";
|
||||
/** Epoch ms of the previous loader activation, for launch-cadence telemetry. */
|
||||
export const LAST_ACTIVATION_STATE_KEY = "cline.rollout.lastActivationAt";
|
||||
|
||||
/**
|
||||
* PostHog rollout flag (created in the Cline PostHog project). Must be a
|
||||
* plain BOOLEAN release flag with a percentage rollout.
|
||||
*
|
||||
* The assignment is TWO-WAY: each background refresh caches exactly what the
|
||||
* flag says (true => next, anything else => legacy) for the next window, so
|
||||
* dialing the percentage down moves machines back to legacy on their next
|
||||
* reload — the single emergency lever is "set the rollout to 0%". Demoted
|
||||
* machines keep their settings/creds (the state files round-trip), but tasks
|
||||
* created on the SDK bundle aren't visible in legacy's history until
|
||||
* re-promoted, and tokens rotated on next may require re-auth on legacy.
|
||||
*/
|
||||
export const ROLLOUT_FLAG = "ext-sdk-bundle-rollout";
|
||||
|
||||
/** Env var for local dev / e2e to force a bundle. Beats everything. */
|
||||
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE";
|
||||
|
||||
/**
|
||||
* User-visible escape hatch: `<prefix>.rollout.bundleOverride` in VS Code
|
||||
* settings ("auto" | "next" | "legacy") — see settingSection() for the
|
||||
* identity-dependent section name. Editable from settings.json without
|
||||
* touching mementos, beats the remote flag in either direction, applies on
|
||||
* window reload. Injected into the union manifest by gen-manifest.mjs — keep
|
||||
* the schema there in sync with these constants.
|
||||
*/
|
||||
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride";
|
||||
|
||||
function asBundle(value: unknown): Bundle | undefined {
|
||||
return value === "next" || value === "legacy" ? value : undefined;
|
||||
}
|
||||
|
||||
export interface CohortInputs {
|
||||
/** CLINE_BUNDLE_OVERRIDE, if set. */
|
||||
envOverride: string | undefined;
|
||||
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
|
||||
settingOverride: string | undefined;
|
||||
/** Cached assignment from the previous window's background flag refresh. */
|
||||
cached: string | undefined;
|
||||
/** The next bundle failed to activate on this VSIX version before. */
|
||||
previousFailure: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which bundle to activate for this window. Must be synchronous and
|
||||
* never block on the network: it only consumes state cached by the previous
|
||||
* window's background refresh, so a percentage change applies on the next
|
||||
* window reload, mirroring how VS Code's own experiments behave.
|
||||
*/
|
||||
export function decideBundle(inputs: CohortInputs): Bundle {
|
||||
const forced =
|
||||
asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride);
|
||||
if (forced) {
|
||||
return forced;
|
||||
}
|
||||
if (inputs.previousFailure) {
|
||||
return "legacy";
|
||||
}
|
||||
return inputs.cached === "next" ? "next" : "legacy";
|
||||
}
|
||||
|
||||
/** Which override produced the decision, if any — reported on the activation event. */
|
||||
export function decisionOverrideSource(
|
||||
inputs: Pick<CohortInputs, "envOverride" | "settingOverride">,
|
||||
): "env" | "setting" | undefined {
|
||||
if (asBundle(inputs.envOverride)) {
|
||||
return "env";
|
||||
}
|
||||
if (asBundle(inputs.settingOverride)) {
|
||||
return "setting";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PostHog /decide (v3) response into the assignment to cache for the
|
||||
* next window, or undefined when the response is malformed (leave the cached
|
||||
* assignment untouched — sticky on transient failures).
|
||||
*
|
||||
* Deliberately strict so a mis-configured flag fails SAFE toward legacy: only
|
||||
* boolean `true` promotes. A multivariate variant string, a number, a payload,
|
||||
* or a missing/deleted flag all resolve to legacy — the flag must stay a plain
|
||||
* boolean release flag with a percentage rollout.
|
||||
*/
|
||||
export function parseRolloutAssignment(response: unknown): Bundle | undefined {
|
||||
const flags = (
|
||||
response as { featureFlags?: Record<string, unknown> } | undefined
|
||||
)?.featureFlags;
|
||||
if (!flags || typeof flags !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return flags[ROLLOUT_FLAG] === true ? "next" : "legacy";
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
BUNDLE_OVERRIDE_ENV,
|
||||
type Bundle,
|
||||
bundleContextKey,
|
||||
COHORT_STATE_KEY,
|
||||
decideBundle,
|
||||
decisionOverrideSource,
|
||||
FAILED_VERSION_STATE_KEY,
|
||||
type IdPrefix,
|
||||
idPrefix,
|
||||
LAST_ACTIVATION_STATE_KEY,
|
||||
SETTING_BUNDLE_OVERRIDE,
|
||||
settingSection,
|
||||
} from "./cohort";
|
||||
import { refreshCohort, reportLoaderDecision } from "./rollout";
|
||||
import { scopedContext } from "./scoped-context";
|
||||
|
||||
/**
|
||||
* Cline rollout loader.
|
||||
*
|
||||
* The VSIX ships two complete, independently built extension bundles:
|
||||
* next/ — the SDK-based extension (built from main's apps/vscode)
|
||||
* legacy/ — the pre-SDK extension (built from the legacy-extension branch)
|
||||
*
|
||||
* This entrypoint picks exactly one per window — from state cached by the
|
||||
* previous window's background flag refresh, never from a blocking network
|
||||
* call — activates it with a context whose install-root paths point into its
|
||||
* subdirectory, and delegates everything else to it. If the next bundle throws
|
||||
* during activation, the loader disposes whatever it half-registered, pins
|
||||
* this VSIX version back to legacy, and activates legacy instead.
|
||||
*/
|
||||
|
||||
// Resolved at runtime relative to the installed VSIX root; must stay opaque to
|
||||
// esbuild so the bundles aren't inlined into the loader.
|
||||
const requireFromVsixRoot = createRequire(__filename);
|
||||
|
||||
interface BundleModule {
|
||||
activate(context: vscode.ExtensionContext): Promise<unknown> | unknown;
|
||||
deactivate?(): Promise<void> | void;
|
||||
/**
|
||||
* Exported by both bundles' entrypoints (see rollout-metadata.ts on each
|
||||
* branch): captures the AUTHORITATIVE `extension.rollout.bundle_activated`
|
||||
* event through the bundle's own variant-attributed telemetry pipeline.
|
||||
* Optional so the loader keeps working against a bundle built before the
|
||||
* export existed.
|
||||
*/
|
||||
reportRolloutActivation?(input: {
|
||||
attemptedBundle: Bundle;
|
||||
actualBundle: Bundle;
|
||||
fallback: boolean;
|
||||
error?: unknown;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
let activeBundle: { module: BundleModule; name: Bundle } | undefined;
|
||||
|
||||
interface ActivationMeta {
|
||||
msSinceLastActivation?: number;
|
||||
override?: "env" | "setting";
|
||||
}
|
||||
|
||||
/** Set when the original decision crashed and this activation is the fallback. */
|
||||
interface FallbackFrom {
|
||||
attempted: Bundle;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
const loaderVersion: string =
|
||||
context.extension.packageJSON?.version ?? "unknown";
|
||||
const prefix = idPrefix(context.extension.packageJSON?.name);
|
||||
|
||||
// Launch-cadence telemetry: how stale the previous activation is bounds how
|
||||
// fast a percentage change can actually reach users' windows.
|
||||
const lastActivationAt = context.globalState.get<number>(
|
||||
LAST_ACTIVATION_STATE_KEY,
|
||||
);
|
||||
const now = Date.now();
|
||||
void context.globalState.update(LAST_ACTIVATION_STATE_KEY, now);
|
||||
|
||||
const overrides = {
|
||||
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
|
||||
settingOverride: vscode.workspace
|
||||
.getConfiguration(settingSection(prefix))
|
||||
.get<string>(SETTING_BUNDLE_OVERRIDE),
|
||||
};
|
||||
const bundle = decideBundle({
|
||||
...overrides,
|
||||
cached: context.globalState.get<string>(COHORT_STATE_KEY),
|
||||
previousFailure:
|
||||
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) ===
|
||||
loaderVersion,
|
||||
});
|
||||
const meta: ActivationMeta = {
|
||||
msSinceLastActivation:
|
||||
typeof lastActivationAt === "number" && lastActivationAt <= now
|
||||
? now - lastActivationAt
|
||||
: undefined,
|
||||
override: decisionOverrideSource(overrides),
|
||||
};
|
||||
|
||||
return activateBundle(context, prefix, bundle, loaderVersion, meta, true);
|
||||
}
|
||||
|
||||
async function activateBundle(
|
||||
context: vscode.ExtensionContext,
|
||||
prefix: IdPrefix,
|
||||
bundle: Bundle,
|
||||
loaderVersion: string,
|
||||
meta: ActivationMeta,
|
||||
refreshAssignmentOnSuccess: boolean,
|
||||
fallbackFrom?: FallbackFrom,
|
||||
): Promise<unknown> {
|
||||
// Menus/keybindings gated per cohort in package.json key off this.
|
||||
await vscode.commands.executeCommand(
|
||||
"setContext",
|
||||
bundleContextKey(prefix),
|
||||
bundle === "next",
|
||||
);
|
||||
|
||||
const subscriptionsBefore = context.subscriptions.length;
|
||||
try {
|
||||
const module = requireFromVsixRoot(
|
||||
path.join(__dirname, bundle, "dist", "extension.js"),
|
||||
) as BundleModule;
|
||||
const api = await module.activate(scopedContext(context, bundle));
|
||||
activeBundle = { module, name: bundle };
|
||||
// Cache the next window's assignment only after the originally selected
|
||||
// bundle activates. A crash fallback must not start a refresh that could
|
||||
// promote the cohort back to next after the handler pins it to legacy.
|
||||
if (refreshAssignmentOnSuccess) {
|
||||
void refreshCohort(context).catch(() => {});
|
||||
}
|
||||
// Authoritative activation event, captured by the bundle's own telemetry
|
||||
// (built with CLINE_ROLLOUT_VARIANT). On fallback this runs in the legacy
|
||||
// bundle — next's pipeline is the thing that just crashed.
|
||||
if (typeof module.reportRolloutActivation === "function") {
|
||||
void module
|
||||
.reportRolloutActivation({
|
||||
attemptedBundle: fallbackFrom?.attempted ?? bundle,
|
||||
actualBundle: bundle,
|
||||
fallback: fallbackFrom !== undefined,
|
||||
error: fallbackFrom?.error,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// The loader's own decision event fires once per window: the fallback
|
||||
// path already reported (fallback: true) from the catch block below.
|
||||
if (!fallbackFrom) {
|
||||
void reportLoaderDecision(context, bundle, {
|
||||
...meta,
|
||||
fallback: false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
showNightlyBundleIndicator(context, prefix, bundle, meta, fallbackFrom);
|
||||
return api;
|
||||
} catch (error) {
|
||||
if (bundle === "legacy") {
|
||||
// Nothing left to fall back to; let VS Code surface the failure. When
|
||||
// this was already the crash fallback, no bundle telemetry pipeline is
|
||||
// alive — the loader's direct event is the only record.
|
||||
void reportLoaderDecision(context, "legacy", {
|
||||
...meta,
|
||||
attemptedBundle: fallbackFrom?.attempted ?? "legacy",
|
||||
fallback: fallbackFrom !== undefined,
|
||||
doubleFailure: fallbackFrom !== undefined,
|
||||
errorMessage: formatActivationError(error),
|
||||
}).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
console.error(
|
||||
"[cline-rollout] next bundle failed to activate, falling back to legacy:",
|
||||
error,
|
||||
);
|
||||
disposeSubscriptionsAddedAfter(context, subscriptionsBefore);
|
||||
// Pin this VSIX version to legacy so we don't crash-loop every window.
|
||||
// A new release (new version string) gets to try next again.
|
||||
await context.globalState.update(FAILED_VERSION_STATE_KEY, loaderVersion);
|
||||
await context.globalState.update(COHORT_STATE_KEY, "legacy");
|
||||
void reportLoaderDecision(context, "legacy", {
|
||||
...meta,
|
||||
attemptedBundle: "next",
|
||||
fallback: true,
|
||||
errorMessage: formatActivationError(error),
|
||||
}).catch(() => {});
|
||||
return activateBundle(
|
||||
context,
|
||||
prefix,
|
||||
"legacy",
|
||||
loaderVersion,
|
||||
meta,
|
||||
false,
|
||||
{ attempted: "next", error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatActivationError(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? `${error.message}\n${error.stack ?? ""}`.slice(0, 2000)
|
||||
: String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nightly-only visible indicator of which bundle this window is running.
|
||||
* The stable combined VSIX (and any ordinary build) never shows it: the
|
||||
* prefix is derived from the packaged manifest name. Best-effort — the
|
||||
* indicator must never take down an otherwise successful activation.
|
||||
*/
|
||||
function showNightlyBundleIndicator(
|
||||
context: vscode.ExtensionContext,
|
||||
prefix: IdPrefix,
|
||||
bundle: Bundle,
|
||||
meta: ActivationMeta,
|
||||
fallbackFrom: FallbackFrom | undefined,
|
||||
) {
|
||||
if (prefix !== "cline-nightly") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const item = vscode.window.createStatusBarItem(
|
||||
vscode.StatusBarAlignment.Right,
|
||||
-1000,
|
||||
);
|
||||
item.text = bundle === "next" ? "Cline: Next" : "Cline: Legacy";
|
||||
const detail = fallbackFrom
|
||||
? "crash fallback from the next bundle"
|
||||
: meta.override
|
||||
? `forced by ${meta.override === "env" ? `the ${BUNDLE_OVERRIDE_ENV} env var` : "the bundleOverride setting"}`
|
||||
: "rollout assignment";
|
||||
item.tooltip = `Cline nightly A/B rollout: running the ${bundle === "next" ? "next (SDK)" : "legacy"} bundle (${detail}).`;
|
||||
item.show();
|
||||
context.subscriptions.push(item);
|
||||
} catch (error) {
|
||||
console.warn("[cline-rollout] could not show bundle indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose anything a failed activation managed to register before it threw. */
|
||||
function disposeSubscriptionsAddedAfter(
|
||||
context: vscode.ExtensionContext,
|
||||
startIndex: number,
|
||||
) {
|
||||
const added = context.subscriptions.splice(startIndex);
|
||||
for (const disposable of added) {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch {
|
||||
// best effort — a broken disposable must not block the fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
return activeBundle?.module.deactivate?.();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { machineId } from "node-machine-id";
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
type Bundle,
|
||||
COHORT_STATE_KEY,
|
||||
parseRolloutAssignment,
|
||||
ROLLOUT_FLAG,
|
||||
} from "./cohort";
|
||||
|
||||
/**
|
||||
* Same PostHog project + reverse proxy the extension's telemetry uses.
|
||||
* The API key is injected at build time by CI (see esbuild.mjs), matching how
|
||||
* apps/vscode injects TELEMETRY_SERVICE_API_KEY. Local builds without the key
|
||||
* skip all network calls, so the loader defaults everyone to legacy.
|
||||
*/
|
||||
const POSTHOG_HOST = "https://data.cline.bot";
|
||||
const POSTHOG_API_KEY = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const FEATURE_FLAG_CALLED_EVENT = "$feature_flag_called";
|
||||
|
||||
/**
|
||||
* Mirror the distinct-id derivation in apps/vscode
|
||||
* (src/services/logging/distinctId.ts) so PostHog evaluates the rollout flag
|
||||
* against the same id the bundles report telemetry with — otherwise cohort
|
||||
* membership can't be correlated with cohort behavior in dashboards.
|
||||
* Falls back to vscode.env.machineId rather than generating + persisting a new
|
||||
* id: the loader must never write to the shared ~/.cline state files.
|
||||
*/
|
||||
async function getDistinctId(): Promise<string> {
|
||||
const generated = await readSharedGlobalStateKey("cline.generatedMachineId");
|
||||
if (typeof generated === "string" && generated.length > 0) {
|
||||
return generated;
|
||||
}
|
||||
try {
|
||||
const id = await machineId();
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return vscode.env.machineId;
|
||||
}
|
||||
|
||||
/** Read one key from the file-backed global state both bundles share. */
|
||||
async function readSharedGlobalStateKey(key: string): Promise<unknown> {
|
||||
try {
|
||||
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline");
|
||||
const raw = await readFile(
|
||||
path.join(clineDir, "data", "globalState.json"),
|
||||
"utf8",
|
||||
);
|
||||
const state = JSON.parse(raw);
|
||||
return state?.[key];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
url: string,
|
||||
body: object,
|
||||
): Promise<Response | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAssignment(
|
||||
distinctId: string,
|
||||
): Promise<Bundle | undefined> {
|
||||
if (!POSTHOG_API_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
const response = await postJson(`${POSTHOG_HOST}/decide?v=3`, {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const decideResponse = await response.json();
|
||||
const assignment = parseRolloutAssignment(decideResponse);
|
||||
if (!assignment) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Mirror FeatureFlagsService/PostHog SDK exposure tracking for this
|
||||
// loader-owned flag evaluation. This event is intentionally not gated by
|
||||
// telemetry opt-out: feature-flag evaluation remains enabled so PostHog can
|
||||
// correctly attribute rollout cohorts, while loader_decision below still
|
||||
// respects user/host telemetry settings.
|
||||
void reportFeatureFlagCalled(
|
||||
distinctId,
|
||||
getRolloutFlagResponse(decideResponse),
|
||||
).catch(() => {});
|
||||
|
||||
return assignment;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getRolloutFlagResponse(response: unknown): unknown {
|
||||
const flags = (
|
||||
response as { featureFlags?: Record<string, unknown> } | undefined
|
||||
)?.featureFlags;
|
||||
return flags && typeof flags === "object" ? flags[ROLLOUT_FLAG] : undefined;
|
||||
}
|
||||
|
||||
async function reportFeatureFlagCalled(
|
||||
distinctId: string,
|
||||
flagResponse: unknown,
|
||||
): Promise<void> {
|
||||
if (!POSTHOG_API_KEY) {
|
||||
return;
|
||||
}
|
||||
await postJson(`${POSTHOG_HOST}/capture/`, {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
event: FEATURE_FLAG_CALLED_EVENT,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
$feature_flag: ROLLOUT_FLAG,
|
||||
$feature_flag_response: flagResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Background refresh: evaluate the rollout flag and cache exactly what it
|
||||
* says for the NEXT window (two-way: dialing the percentage down demotes on
|
||||
* the next reload). Never affects the bundle already activated in this
|
||||
* window, and failures leave the cached assignment untouched (sticky on
|
||||
* transient errors only).
|
||||
*/
|
||||
export async function refreshCohort(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<void> {
|
||||
const distinctId = await getDistinctId();
|
||||
const assignment = await fetchAssignment(distinctId);
|
||||
if (!assignment) {
|
||||
return;
|
||||
}
|
||||
await context.globalState.update(COHORT_STATE_KEY, assignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader's own decision event. Distinct from the AUTHORITATIVE
|
||||
* `extension.rollout.bundle_activated` event, which the activated bundle
|
||||
* itself captures through its variant-attributed telemetry pipeline (the
|
||||
* loader triggers it via the bundle's reportRolloutActivation export — see
|
||||
* src/extension.ts). This event carries the loader-side metadata that event
|
||||
* can't (override source, launch cadence, loader version) and is the only
|
||||
* signal left when BOTH bundles fail to activate.
|
||||
*/
|
||||
export const LOADER_DECISION_EVENT = "extension.rollout.loader_decision";
|
||||
|
||||
/**
|
||||
* Report the loader's bundle decision (and whether it was a crash fallback).
|
||||
* Feature-flag evaluation is always allowed (matching the extension's
|
||||
* FeatureFlagsService), but event capture respects the user's telemetry
|
||||
* opt-out and VS Code's global telemetry setting.
|
||||
*/
|
||||
export async function reportLoaderDecision(
|
||||
context: vscode.ExtensionContext,
|
||||
bundle: Bundle,
|
||||
options: {
|
||||
fallback: boolean;
|
||||
/** Bundle the loader originally decided on; differs from `bundle` on fallback. */
|
||||
attemptedBundle?: Bundle;
|
||||
/** Both bundles threw — nothing activated, and no bundle telemetry exists. */
|
||||
doubleFailure?: boolean;
|
||||
errorMessage?: string;
|
||||
/** Time since the previous loader activation on this machine, if known. */
|
||||
msSinceLastActivation?: number;
|
||||
/** Whether an env var or user setting forced this bundle. */
|
||||
override?: "env" | "setting";
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!POSTHOG_API_KEY) {
|
||||
return;
|
||||
}
|
||||
const telemetrySetting = await readSharedGlobalStateKey("telemetrySetting");
|
||||
if (telemetrySetting === "disabled" || !vscode.env.isTelemetryEnabled) {
|
||||
return;
|
||||
}
|
||||
const distinctId = await getDistinctId();
|
||||
await postJson(`${POSTHOG_HOST}/capture/`, {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
event: LOADER_DECISION_EVENT,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
bundle,
|
||||
attempted_bundle: options.attemptedBundle ?? bundle,
|
||||
fallback: options.fallback,
|
||||
double_failure: options.doubleFailure,
|
||||
error_message: options.errorMessage,
|
||||
// Launch-cadence distribution: how long promotions take to reach real
|
||||
// windows tells us how fast the rollout percentage can safely be dialed.
|
||||
ms_since_last_activation: options.msSinceLastActivation,
|
||||
override: options.override,
|
||||
loader_version: context.extension.packageJSON?.version,
|
||||
// Separates nightly traffic from the (future) stable combined VSIX.
|
||||
extension_name: context.extension.packageJSON?.name,
|
||||
vscode_version: vscode.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import type { Bundle } from "./cohort";
|
||||
|
||||
/**
|
||||
* Wrap the real ExtensionContext so a bundle living under `<vsix root>/<sub>/`
|
||||
* resolves extension-root-relative resources (webview-ui build, walkthrough
|
||||
* assets, bundled codicons, ...) from its own subtree, without either codebase
|
||||
* knowing it was relocated.
|
||||
*
|
||||
* Only install-root properties are redirected. Storage-related properties
|
||||
* (globalState, workspaceState, secrets, globalStorageUri, storageUri, logUri)
|
||||
* intentionally pass through untouched: both bundles must keep sharing the
|
||||
* exact storage the standalone extension used, so user state survives cohort
|
||||
* changes and VSIX upgrades.
|
||||
*/
|
||||
export function scopedContext(
|
||||
context: vscode.ExtensionContext,
|
||||
sub: Bundle,
|
||||
): vscode.ExtensionContext {
|
||||
const extensionUri = vscode.Uri.joinPath(context.extensionUri, sub);
|
||||
const extensionPath = extensionUri.fsPath;
|
||||
|
||||
const scopedExtension = new Proxy(context.extension, {
|
||||
get(target, prop, _receiver) {
|
||||
if (prop === "extensionUri") {
|
||||
return extensionUri;
|
||||
}
|
||||
if (prop === "extensionPath") {
|
||||
return extensionPath;
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
|
||||
const overrides = new Map<PropertyKey, unknown>([
|
||||
["extensionUri", extensionUri],
|
||||
["extensionPath", extensionPath],
|
||||
[
|
||||
"asAbsolutePath",
|
||||
(relativePath: string) => path.join(extensionPath, relativePath),
|
||||
],
|
||||
["extension", scopedExtension],
|
||||
]);
|
||||
|
||||
return new Proxy(context, {
|
||||
get(target, prop, _receiver) {
|
||||
if (overrides.has(prop)) {
|
||||
return overrides.get(prop);
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as vscode.ExtensionContext;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node", "vscode"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -63,7 +63,7 @@ service ModelsService {
|
||||
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
|
||||
// Writes provider configuration fields and returns redacted effective configuration
|
||||
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
|
||||
// Commits a mode-specific model selection atomically with its model metadata
|
||||
// Commits a mode-specific model ID with optional user-authored metadata overrides
|
||||
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,36 @@ message OpenRouterModelInfo {
|
||||
optional ApiFormat api_format = 16;
|
||||
}
|
||||
|
||||
// User-authored per-model metadata stored in models.json.
|
||||
//
|
||||
// Semantics:
|
||||
// - `capabilities` accepts only the SDK ModelCapability values (e.g.
|
||||
// "images", "tools", "prompt-cache", "reasoning", "files"); unknown
|
||||
// strings are silently dropped by the host. The array is additive over
|
||||
// the base metadata; the explicit supports_* booleans win when both are
|
||||
// present.
|
||||
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
|
||||
// format only when true; `api_format` is canonical.
|
||||
// - Invalid numbers (non-positive token limits, negative prices or
|
||||
// temperature, non-finite values) are silently discarded, not rejected.
|
||||
message ModelOverrides {
|
||||
optional string name = 1;
|
||||
optional int64 max_tokens = 2;
|
||||
optional int64 context_window = 3;
|
||||
optional int64 max_input_tokens = 4;
|
||||
repeated string capabilities = 5;
|
||||
optional bool supports_vision = 6;
|
||||
optional bool supports_attachments = 7;
|
||||
optional bool supports_reasoning = 8;
|
||||
optional double input_price = 9;
|
||||
optional double output_price = 10;
|
||||
optional double cache_reads_price = 11;
|
||||
optional double cache_writes_price = 12;
|
||||
optional double temperature = 13;
|
||||
optional ApiFormat api_format = 14;
|
||||
optional bool is_r1_format_required = 15;
|
||||
}
|
||||
|
||||
// Shared response message for model information
|
||||
message OpenRouterCompatibleModelInfo {
|
||||
map<string, OpenRouterModelInfo> models = 1;
|
||||
@@ -222,12 +252,16 @@ message ProviderConfigResponse {
|
||||
optional CommittedModelSelection act_selection = 11;
|
||||
optional AwsProviderConfig aws = 12;
|
||||
optional GcpProviderConfig gcp = 13;
|
||||
// Provider-level context window (providers.json `contextWindow`). Used by
|
||||
// bring-your-own-model providers (e.g. Ollama, where it maps to num_ctx).
|
||||
optional int32 context_window = 14;
|
||||
}
|
||||
|
||||
message CommittedModelSelection {
|
||||
string provider_id = 1;
|
||||
string model_id = 2;
|
||||
OpenRouterModelInfo model_info = 3;
|
||||
optional ModelOverrides overrides = 4;
|
||||
}
|
||||
|
||||
message ProviderReasoningPatch {
|
||||
@@ -249,6 +283,8 @@ message WriteProviderConfigPatch {
|
||||
optional bool clear_headers = 10;
|
||||
optional AwsProviderConfig aws = 11;
|
||||
optional GcpProviderConfig gcp = 12;
|
||||
// Provider-level context window; 0 clears the setting.
|
||||
optional int32 context_window = 13;
|
||||
}
|
||||
|
||||
message WriteProviderConfigRequest {
|
||||
@@ -257,10 +293,18 @@ message WriteProviderConfigRequest {
|
||||
}
|
||||
|
||||
message CommitModelSelectionRequest {
|
||||
// Field 4 carried `OpenRouterModelInfo model_info` in earlier releases.
|
||||
// Reusing the number with a different message type mis-decodes on version
|
||||
// skew, so the retired field stays reserved.
|
||||
reserved 4;
|
||||
reserved "model_info";
|
||||
string provider_id = 1;
|
||||
string mode = 2;
|
||||
string model_id = 3;
|
||||
OpenRouterModelInfo model_info = 4;
|
||||
// Tri-state: ABSENT leaves the model's stored overrides unchanged, an
|
||||
// explicitly EMPTY message clears them, and a populated message replaces
|
||||
// them wholesale (no per-field merge).
|
||||
optional ModelOverrides overrides = 5;
|
||||
}
|
||||
|
||||
message ClineRecommendedModel {
|
||||
|
||||
@@ -16,6 +16,9 @@ service TaskService {
|
||||
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
|
||||
// Cancels the currently running background command
|
||||
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
|
||||
// Detaches the running foreground terminal command ("Proceed While Running"):
|
||||
// the agent receives the partial output and a log file path for the rest.
|
||||
rpc proceedWhileRunningCommand(EmptyRequest) returns (Empty);
|
||||
// Clears the current task
|
||||
rpc clearTask(EmptyRequest) returns (Empty);
|
||||
// Gets the total size of all tasks
|
||||
|
||||
+12
@@ -56,4 +56,16 @@ describe("parseYamlFrontmatter", () => {
|
||||
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
|
||||
expect(result.body.trim()).to.equal("Content")
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151
|
||||
// A leading UTF-8 BOM (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not
|
||||
// prevent frontmatter from being recognized.
|
||||
it("parses frontmatter correctly when the content has a leading UTF-8 BOM", () => {
|
||||
const input = `\uFEFF---\nname: my-skill\ndescription: A test skill\n---\n# my-skill\nThis is a test skill.`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.parseError).to.equal(undefined)
|
||||
expect(result.data).to.deep.equal({ name: "my-skill", description: "A test skill" })
|
||||
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,6 +139,34 @@ Instructions here`)
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151:
|
||||
// SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's "UTF-8 with BOM"
|
||||
// encoding) were silently skipped because the frontmatter regex required "---" at the
|
||||
// very start of the file and never accounted for the leading \uFEFF byte sequence.
|
||||
it("should discover skills whose SKILL.md starts with a UTF-8 BOM", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`\uFEFF---
|
||||
name: my-skill
|
||||
description: A test skill
|
||||
---
|
||||
# my-skill
|
||||
This is a test skill.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("my-skill")
|
||||
expect(skills[0].description).to.equal("A test skill")
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
it("should discover skills from project .clinerules/skills directory", async () => {
|
||||
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const skillDir = path.join(projectSkillsDir, "explaining-code")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripUtf8Bom } from "@cline/shared"
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
export type FrontmatterParseResult = {
|
||||
@@ -35,11 +36,16 @@ export type FrontmatterParseResult = {
|
||||
* - If no frontmatter exists, returns data={} and body=original markdown.
|
||||
*/
|
||||
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
|
||||
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
|
||||
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
|
||||
const normalizedMarkdown = stripUtf8Bom(markdown)
|
||||
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = markdown.match(frontmatterRegex)
|
||||
const match = normalizedMarkdown.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, body: markdown, hadFrontmatter: false }
|
||||
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
@@ -48,6 +54,6 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
return { data, body, hadFrontmatter: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
|
||||
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
|
||||
import { ApiFormat, ModelOverrides } from "@/shared/proto/cline/models"
|
||||
import type { ProviderCatalogController } from "../providerCatalogShared"
|
||||
|
||||
type TestStateManager = {
|
||||
@@ -153,6 +153,22 @@ describe("provider model catalog handlers", () => {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
auth: { accessToken: "SECRET_SENTINEL_ACCESS", refreshToken: "SECRET_SENTINEL_REFRESH", accountId: "acct-1" },
|
||||
})
|
||||
vi.mocked(store.readSelection).mockImplementation((_providerId, mode) =>
|
||||
mode === "act"
|
||||
? {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
capabilities: ["tools", "custom-capability"],
|
||||
inputPrice: 1.25,
|
||||
supportsVision: false,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
modelInfo: { name: "Custom model", contextWindow: 64_000, supportsPromptCache: false },
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
const response = await readProviderConfig(controller, { value: "cline" })
|
||||
@@ -165,10 +181,24 @@ describe("provider model catalog handlers", () => {
|
||||
hasRefreshToken: true,
|
||||
accountId: "acct-1",
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
expect(response.actSelection).toMatchObject({
|
||||
providerId: "cline",
|
||||
modelId: "custom-model",
|
||||
modelInfo: { name: "Custom model", contextWindow: 64_000 },
|
||||
overrides: {
|
||||
capabilities: ["tools", "custom-capability"],
|
||||
inputPrice: 1.25,
|
||||
supportsVision: false,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_API_KEY")
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_ACCESS")
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_REFRESH")
|
||||
})
|
||||
|
||||
it("writeProviderConfig writes a patch and returns redacted updated config", async () => {
|
||||
it("writeProviderConfig writes a patch and returns a redacted response", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("ollama")
|
||||
const updatedConfig: EffectiveProviderConfig = {
|
||||
@@ -188,8 +218,10 @@ describe("provider model catalog handlers", () => {
|
||||
apiKey: "SECRET_SENTINEL_OLLAMA",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
})
|
||||
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_OLLAMA".length)
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
expect(response).toMatchObject({
|
||||
apiKeyLength: "SECRET_SENTINEL_OLLAMA".length,
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_OLLAMA")
|
||||
})
|
||||
|
||||
it("writeProviderConfig can explicitly clear headers", async () => {
|
||||
@@ -210,7 +242,7 @@ describe("provider model catalog handlers", () => {
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
|
||||
})
|
||||
|
||||
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
|
||||
it("commitModelSelection validates mode and commits model settings", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
@@ -224,22 +256,20 @@ describe("provider model catalog handlers", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: OpenRouterModelInfo.create({
|
||||
overrides: ModelOverrides.create({
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextWindow: 456,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
capabilities: ["prompt-cache"],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: expect.objectContaining({
|
||||
overrides: expect.objectContaining({
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextWindow: 456,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
capabilities: ["prompt-cache"],
|
||||
}),
|
||||
})
|
||||
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
|
||||
@@ -249,6 +279,49 @@ describe("provider model catalog handlers", () => {
|
||||
expect(stateManager.flushPendingState).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// The overrides field is tri-state: absent preserves the model's stored
|
||||
// overrides, an explicitly empty message clears them, and a populated
|
||||
// message replaces them. The two boundary cases are pinned here because
|
||||
// the webview relies on both (see useProviderConfig.test.ts).
|
||||
it("commitModelSelection maps an ABSENT overrides field to undefined (preserve stored overrides)", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
await commitModelSelection(controller, {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "deepseek-v4-flash",
|
||||
overrides: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection maps an EMPTY overrides message to an empty object (clear stored overrides)", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
await commitModelSelection(controller, {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
overrides: ModelOverrides.create({}),
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "deepseek-v4-flash",
|
||||
overrides: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection reports provider changes when config is initialized", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
@@ -268,10 +341,7 @@ describe("provider model catalog handlers", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: OpenRouterModelInfo.create({
|
||||
name: "DeepSeek V4 Flash",
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
}),
|
||||
overrides: ModelOverrides.create({ name: "DeepSeek V4 Flash" }),
|
||||
})
|
||||
|
||||
expect(handleApiConfigurationChanged).toHaveBeenCalledWith({}, { actModeApiProvider: "deepseek" })
|
||||
@@ -289,7 +359,7 @@ describe("provider model catalog handlers", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "invalid",
|
||||
modelId: "deepseek-v4-flash",
|
||||
modelInfo: OpenRouterModelInfo.create({ supportsPromptCache: true }),
|
||||
overrides: ModelOverrides.create({ capabilities: ["prompt-cache"] }),
|
||||
}),
|
||||
).rejects.toThrow('mode must be "plan" or "act"')
|
||||
expect(store.commitSelection).not.toHaveBeenCalled()
|
||||
|
||||
@@ -75,7 +75,6 @@ describe("provider model catalog backend smoke", () => {
|
||||
providerId: "deepseek",
|
||||
mode: "act",
|
||||
modelId,
|
||||
modelInfo,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import type {
|
||||
EffectiveProviderConfig,
|
||||
Mode,
|
||||
ModelSelection,
|
||||
ModelSelectionOverrides,
|
||||
ProviderCatalog,
|
||||
ProviderConfigPatch,
|
||||
ProviderConfigStore,
|
||||
ProviderId,
|
||||
ProviderListing,
|
||||
ProviderModelsResult,
|
||||
ResolvedModelSelection,
|
||||
} from "@/sdk/model-catalog/contracts"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import {
|
||||
@@ -17,13 +19,15 @@ import {
|
||||
CommitModelSelectionRequest,
|
||||
CommittedModelSelection,
|
||||
GcpProviderConfig,
|
||||
ModelOverrides as ModelOverridesProto,
|
||||
OpenRouterModelInfo,
|
||||
ProviderConfigResponse,
|
||||
ProviderListing as ProviderListingProto,
|
||||
ProviderModelsResponse,
|
||||
WriteProviderConfigPatch,
|
||||
} from "@/shared/proto/cline/models"
|
||||
import { fromProtobufModelInfo, toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import { fromProtobufModelOverrides, toProtobufModelOverrides } from "@/shared/proto-conversions/models/modelOverrides"
|
||||
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import type { GlobalStateAndSettings } from "@/shared/storage/state-keys"
|
||||
|
||||
export interface ProviderCatalogController {
|
||||
@@ -94,7 +98,11 @@ function toProtobufModels(models: ReadonlyMap<string, ModelInfo>): Record<string
|
||||
return result
|
||||
}
|
||||
|
||||
function toCommittedModelSelectionProto(selection: ModelSelection | undefined): CommittedModelSelection | undefined {
|
||||
function toModelOverridesProto(overrides: ModelSelectionOverrides | undefined): ModelOverridesProto | undefined {
|
||||
return overrides ? toProtobufModelOverrides(overrides) : undefined
|
||||
}
|
||||
|
||||
function toCommittedModelSelectionProto(selection: ResolvedModelSelection | undefined): CommittedModelSelection | undefined {
|
||||
if (!selection) {
|
||||
return undefined
|
||||
}
|
||||
@@ -102,6 +110,7 @@ function toCommittedModelSelectionProto(selection: ModelSelection | undefined):
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
modelInfo: toProtobufModelInfo(selection.modelInfo),
|
||||
overrides: toModelOverridesProto(selection.overrides),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,6 +208,7 @@ export function toRedactedProviderConfigResponse(
|
||||
actSelection: toCommittedModelSelectionProto(store?.readSelection(config.providerId, "act")),
|
||||
aws: toRedactedAwsProviderConfigProto(config.aws),
|
||||
gcp: toRedactedGcpProviderConfigProto(config.gcp),
|
||||
contextWindow: config.contextWindow,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,6 +228,10 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
|
||||
...(protoPatch.apiLine !== undefined ? { apiLine: protoPatch.apiLine } : {}),
|
||||
...(protoPatch.aws !== undefined ? { aws: toAwsProviderConfigPatch(protoPatch) } : {}),
|
||||
...(protoPatch.gcp !== undefined ? { gcp: toGcpProviderConfigPatch(protoPatch) } : {}),
|
||||
// A zero context window over the wire means "clear the setting".
|
||||
...(protoPatch.contextWindow !== undefined
|
||||
? { contextWindow: protoPatch.contextWindow > 0 ? protoPatch.contextWindow : null }
|
||||
: {}),
|
||||
...(protoPatch.accessToken !== undefined || protoPatch.refreshToken !== undefined || protoPatch.accountId !== undefined
|
||||
? {
|
||||
auth: {
|
||||
@@ -241,17 +255,18 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
|
||||
}
|
||||
}
|
||||
|
||||
function toSelectionOverrides(overrides: ModelOverridesProto | undefined): ModelSelectionOverrides | undefined {
|
||||
return fromProtobufModelOverrides(overrides)
|
||||
}
|
||||
|
||||
export function toModelSelection(request: CommitModelSelectionRequest, providerId: ProviderId): ModelSelection {
|
||||
const modelId = request.modelId.trim()
|
||||
if (!modelId) {
|
||||
throw new Error("model_id is required")
|
||||
}
|
||||
if (!request.modelInfo) {
|
||||
throw new Error("model_info is required")
|
||||
}
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
modelInfo: fromProtobufModelInfo(request.modelInfo),
|
||||
overrides: toSelectionOverrides(request.overrides),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ import { type ProviderCatalogController, parseProviderIdRequest } from "./provid
|
||||
* Resolution order:
|
||||
*
|
||||
* 1. Committed selection — the user's most-recently-chosen plan/act
|
||||
* selection in the provider config store. This is the source of
|
||||
* truth for dynamic-list providers (openrouter, openai-compatible,
|
||||
* ollama, lmstudio, requesty, litellm, …) where the picker writes
|
||||
* the live `ModelInfo` into the selection when the user commits.
|
||||
* model ID resolved against SDK catalog metadata, the picker's state
|
||||
* snapshot, and stored overrides by the provider config store. A
|
||||
* selection whose metadata is pure fallback fabrication (no catalog or
|
||||
* state base, no overrides) is deferred behind the catalog steps below
|
||||
* and only returned as a last resort.
|
||||
*
|
||||
* 2. Catalog peek — a non-fetching look-up of the catalog cache for
|
||||
* the provider's current effective config fingerprint. Hits when
|
||||
@@ -41,23 +42,24 @@ export async function resolveModelInfo(
|
||||
const requestedModelId = request.modelId?.trim() || ""
|
||||
|
||||
const store = controller.getProviderConfigStore()
|
||||
// A committed selection whose metadata is pure fallback fabrication (no
|
||||
// catalog/state base and no user overrides) must not shadow the live
|
||||
// catalog below; it is kept only as a last resort before "unknown".
|
||||
let fallbackSelection: ReturnType<typeof store.readSelection>
|
||||
if (requestedModelId) {
|
||||
const actSelection = store.readSelection(providerId, "act")
|
||||
if (actSelection?.modelId === requestedModelId) {
|
||||
for (const mode of ["act", "plan"] as const) {
|
||||
const selection = store.readSelection(providerId, mode)
|
||||
if (selection?.modelId !== requestedModelId) {
|
||||
continue
|
||||
}
|
||||
if (selection.modelInfoSource === "fallback" && !selection.overrides) {
|
||||
fallbackSelection ??= selection
|
||||
continue
|
||||
}
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: actSelection.modelId,
|
||||
modelInfo: toProtobufModelInfo(actSelection.modelInfo),
|
||||
source: "committed-selection",
|
||||
})
|
||||
}
|
||||
|
||||
const planSelection = store.readSelection(providerId, "plan")
|
||||
if (planSelection?.modelId === requestedModelId) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: planSelection.modelId,
|
||||
modelInfo: toProtobufModelInfo(planSelection.modelInfo),
|
||||
modelId: selection.modelId,
|
||||
modelInfo: toProtobufModelInfo(selection.modelInfo),
|
||||
source: "committed-selection",
|
||||
})
|
||||
}
|
||||
@@ -74,7 +76,9 @@ export async function resolveModelInfo(
|
||||
const cached = catalog.peekModels(providerId)
|
||||
if (cached?.ok) {
|
||||
const hit = pickFromCatalog(cached, requestedModelId, allowCustomModelIds)
|
||||
if (hit) {
|
||||
// A default-model substitution answers a question about a different
|
||||
// model; the committed selection, even fallback-grade, is closer.
|
||||
if (hit && (hit.matchedRequested || !fallbackSelection)) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: hit.modelId,
|
||||
@@ -90,7 +94,7 @@ export async function resolveModelInfo(
|
||||
const resolved = await catalog.resolveModels(providerId).catch(() => undefined)
|
||||
if (resolved?.ok) {
|
||||
const hit = pickFromCatalog(resolved, requestedModelId, allowCustomModelIds)
|
||||
if (hit) {
|
||||
if (hit && (hit.matchedRequested || !fallbackSelection)) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: hit.modelId,
|
||||
@@ -100,6 +104,15 @@ export async function resolveModelInfo(
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackSelection) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: fallbackSelection.modelId,
|
||||
modelInfo: toProtobufModelInfo(fallbackSelection.modelInfo),
|
||||
source: "committed-selection",
|
||||
})
|
||||
}
|
||||
|
||||
return ResolveModelInfoResponse.create({
|
||||
providerId,
|
||||
modelId: requestedModelId,
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
mcpHub?: any
|
||||
backgroundCommandRunning?: boolean
|
||||
backgroundCommandTaskId?: string
|
||||
foregroundCommandRunning?: boolean
|
||||
workspaceManager?: any
|
||||
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
|
||||
}): Promise<ExtensionState> {
|
||||
@@ -157,6 +158,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
favoritedModelIds,
|
||||
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
|
||||
backgroundCommandTaskId: controller.backgroundCommandTaskId,
|
||||
foregroundCommandRunning: controller.foregroundCommandRunning ?? false,
|
||||
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
|
||||
primaryRootIndex: controller.workspaceManager?.getPrimaryIndex?.() ?? 0,
|
||||
isMultiRootWorkspace: (controller.workspaceManager?.getRoots?.()?.length ?? 0) > 1,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* "Proceed While Running": detach the in-flight foreground terminal command(s)
|
||||
* so the agent turn continues with the partial output while the commands keep
|
||||
* running in the user's terminal, streaming further output to a log file.
|
||||
*/
|
||||
export async function proceedWhileRunningCommand(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
const controllerWithProceed = controller as Controller & {
|
||||
proceedWhileRunningCommand: () => Promise<void>
|
||||
}
|
||||
await controllerWithProceed.proceedWhileRunningCommand()
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -15,8 +15,9 @@ Designed to be driven from an agentic loop via `curl` commands.
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start the debug harness server
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
# Terminal 1: Start the debug harness server.
|
||||
# Run with node, NOT bun — Playwright's Electron launch times out under bun.
|
||||
node src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
|
||||
# Terminal 2: Interact via curl
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
@@ -27,7 +28,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
## Server Options
|
||||
|
||||
```
|
||||
bun src/dev/debug-harness/server.ts [options]
|
||||
node src/dev/debug-harness/server.ts [options]
|
||||
|
||||
Options:
|
||||
--skip-build Skip building extension/webview (use existing dist/)
|
||||
@@ -42,7 +43,7 @@ Options:
|
||||
```bash
|
||||
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
|
||||
# downloads VSCode, launches it, and connects CDP to the extension host.
|
||||
bun src/dev/debug-harness/server.ts --auto-launch
|
||||
node src/dev/debug-harness/server.ts --auto-launch
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Debug Harness Server
|
||||
@@ -10,7 +10,12 @@
|
||||
* - UI automation (click, type, screenshot) via Playwright
|
||||
*
|
||||
* Usage:
|
||||
* bun src/dev/debug-harness/server.ts [options]
|
||||
* node src/dev/debug-harness/server.ts [options]
|
||||
*
|
||||
* Run with node, not bun: Playwright's _electron.launch() never finishes
|
||||
* attaching to the debugee under bun (the Electron process starts, but the
|
||||
* launch times out), while the same launch works under node. Node >= 22.6
|
||||
* runs this file directly via type stripping.
|
||||
*
|
||||
* Options:
|
||||
* --skip-build Skip building extension/webview
|
||||
@@ -39,7 +44,6 @@ import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { downloadAndUnzipVSCode, SilentReporter } from "@vscode/test-electron"
|
||||
import { _electron, type CDPSession, type ElectronApplication, type Frame, type Page } from "playwright"
|
||||
import WebSocket from "ws"
|
||||
|
||||
const __script_dir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -201,19 +205,21 @@ class CdpClient {
|
||||
|
||||
async connect(wsUrl: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// The runtime's built-in WebSocket (browser-style events), so the
|
||||
// harness has no dependency on the `ws` package.
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.on("open", () => {
|
||||
ws.addEventListener("open", () => {
|
||||
this.ws = ws
|
||||
resolve()
|
||||
})
|
||||
ws.on("error", (e: Error) => {
|
||||
if (!this.ws) reject(e)
|
||||
ws.addEventListener("error", () => {
|
||||
if (!this.ws) reject(new Error(`WebSocket connection failed: ${wsUrl}`))
|
||||
})
|
||||
ws.on("close", () => {
|
||||
ws.addEventListener("close", () => {
|
||||
this.ws = null
|
||||
})
|
||||
ws.on("message", (raw: WebSocket.Data) => {
|
||||
const msg = JSON.parse(raw.toString())
|
||||
ws.addEventListener("message", (event: MessageEvent) => {
|
||||
const msg = JSON.parse(typeof event.data === "string" ? event.data : Buffer.from(event.data).toString())
|
||||
if (msg.id !== undefined) {
|
||||
const p = this.pending.get(msg.id)
|
||||
if (p) {
|
||||
|
||||
@@ -694,4 +694,41 @@ describe("TerminalProcess (Integration Tests)", () => {
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
|
||||
processAny.buffer.should.equal("")
|
||||
})
|
||||
|
||||
it("detach emits continue but keeps line listeners attached and listening", () => {
|
||||
const processAny = process as any
|
||||
const continueEvents: number[] = []
|
||||
const lines: string[] = []
|
||||
process.on("continue", () => continueEvents.push(1))
|
||||
process.on("line", (line) => lines.push(line))
|
||||
|
||||
process.detach()
|
||||
continueEvents.length.should.equal(1)
|
||||
|
||||
// Unlike continue(), detach must not stop listening or drop 'line'
|
||||
// listeners: output after detach still reaches subscribers (this is
|
||||
// what streams the rest of a detached command to the log file).
|
||||
processAny.isListening.should.be.true()
|
||||
processAny.emitIfEol("after detach\n")
|
||||
lines.should.containEql("after detach")
|
||||
})
|
||||
|
||||
it("detach flushes a buffered partial line before emitting continue", () => {
|
||||
const processAny = process as any
|
||||
const events: string[] = []
|
||||
process.on("continue", () => events.push("continue"))
|
||||
process.on("line", (line) => events.push(`line:${line}`))
|
||||
|
||||
// A chunk with no trailing newline stays in the internal buffer.
|
||||
processAny.emitIfEol("partial output")
|
||||
processAny.buffer.should.equal("partial output")
|
||||
|
||||
process.detach()
|
||||
|
||||
// The partial line must reach listeners before 'continue' resolves the
|
||||
// awaited promise; otherwise it is missing from the partial output and
|
||||
// from the log's initial flush.
|
||||
events.should.eql(["line:partial output", "continue"])
|
||||
processAny.buffer.should.equal("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
TRUNCATE_KEEP_LINES,
|
||||
} from "@/integrations/terminal/constants"
|
||||
import type { ITerminalProcess, TerminalCompletionDetails, TerminalProcessEvents } from "@/integrations/terminal/types"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { MarkerlessCompletionCause } from "@/services/telemetry/TelemetryService"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Osc633EventType, Osc633Parser } from "./osc633Parser"
|
||||
import { classifyShellPrompt, getLastLine } from "./shellPromptHeuristics"
|
||||
|
||||
@@ -522,6 +522,23 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the awaited promise while the command keeps running ("Proceed
|
||||
* While Running"). Listeners stay attached and 'line' events keep
|
||||
* flowing — unlike continue() — so callers can stream the remaining
|
||||
* output until the command actually completes. Because 'completed' is
|
||||
* only emitted by the read loop when the command genuinely ends, the
|
||||
* terminal stays busy and is not eligible for reuse until then.
|
||||
*/
|
||||
detach() {
|
||||
// Flush any partial line (no trailing newline yet) so it reaches
|
||||
// listeners before the awaited promise resolves; otherwise it would be
|
||||
// dropped from both the partial output and the log capture if the
|
||||
// command exits without further newline-terminated output.
|
||||
this.emitRemainingBufferIfListening()
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get output that hasn't been retrieved yet.
|
||||
* Truncates if output is too large to prevent context window overflow.
|
||||
|
||||
@@ -63,10 +63,17 @@ export interface ITerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
/**
|
||||
* Continue execution without waiting for completion.
|
||||
* Stops event emission and resolves the promise.
|
||||
* This is called when user clicks "Proceed While Running".
|
||||
*/
|
||||
continue(): void
|
||||
|
||||
/**
|
||||
* Resolve the awaited promise while the command keeps running ("Proceed
|
||||
* While Running"). Unlike continue(), output listeners stay attached and
|
||||
* 'line'/'completed' events keep flowing, so callers can stream the rest
|
||||
* of the output (e.g. to a log file) until the command completes.
|
||||
*/
|
||||
detach(): void
|
||||
|
||||
/**
|
||||
* Get output that hasn't been retrieved yet.
|
||||
* @returns The unretrieved output
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
|
||||
import { SdkDiffEditCoordinator } from "./sdk-diff-edit-coordinator"
|
||||
import { SdkFollowupCoordinator } from "./sdk-followup-coordinator"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
|
||||
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
|
||||
import { SdkMessageCoordinator, type SessionEventListener } from "./sdk-message-coordinator"
|
||||
@@ -204,6 +205,15 @@ export class Controller {
|
||||
// standalone (JetBrains/CLI) host run commands through the SDK's built-in tool.
|
||||
private _terminalManager?: VscodeTerminalManager
|
||||
|
||||
// Registry of in-flight foreground (VS Code terminal) command executions.
|
||||
// Owned here — not by the session — so it survives session rebuilds, which
|
||||
// recreate the tool set. Drives the "Proceed While Running" button.
|
||||
private readonly foregroundCommands = new SdkForegroundCommandCoordinator({
|
||||
onRunningChanged: () => {
|
||||
void this.postStateToWebview()
|
||||
},
|
||||
})
|
||||
|
||||
// Private state kept for stub compatibility
|
||||
private backgroundCommandRunning = false
|
||||
private backgroundCommandTaskId?: string
|
||||
@@ -330,6 +340,7 @@ export class Controller {
|
||||
},
|
||||
onDidBecomeIdle: () => this.handleSessionBecameIdle(),
|
||||
getRemoteConfigIntegration: () => this.remoteConfigCoreIntegration,
|
||||
foregroundCommands: this.foregroundCommands,
|
||||
getTerminalManager: () => {
|
||||
// Guarded by getEffectiveTerminalExecutionMode() at the read sites
|
||||
// (vscode-session-host.ts, sdk-terminal-execution-mode-coordinator.ts):
|
||||
@@ -1174,6 +1185,19 @@ export class Controller {
|
||||
stubWarn("cancelBackgroundCommand")
|
||||
}
|
||||
|
||||
/**
|
||||
* "Proceed While Running": detach every in-flight foreground terminal
|
||||
* command. Each pending run_commands call returns its partial output plus
|
||||
* the log file path the remaining output is redirected to, and the agent
|
||||
* turn continues while the commands keep running in their terminals.
|
||||
*/
|
||||
async proceedWhileRunningCommand(): Promise<void> {
|
||||
const detached = this.foregroundCommands.proceedWhileRunning()
|
||||
if (detached === 0) {
|
||||
Logger.warn("[SdkController] proceedWhileRunningCommand: No foreground command is running")
|
||||
}
|
||||
}
|
||||
|
||||
async cancelQueuedPrompt(promptId: string): Promise<void> {
|
||||
const trimmedPromptId = promptId.trim()
|
||||
if (!trimmedPromptId) {
|
||||
@@ -1868,6 +1892,7 @@ export class Controller {
|
||||
mcpHub: this.mcpHub,
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
foregroundCommandRunning: this.foregroundCommands.isRunning,
|
||||
})
|
||||
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
|
||||
.map(sessionHistoryRecordToHistoryItem)
|
||||
|
||||
@@ -2,6 +2,9 @@ import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { CoreSessionConfig } from "@cline/core"
|
||||
import * as LlmsModels from "@cline/llms"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
buildResumeSessionInput,
|
||||
@@ -15,9 +18,12 @@ import {
|
||||
resolveApiKey,
|
||||
updateHistoryItem,
|
||||
} from "./cline-session-factory"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { createProviderConfigStore } from "./model-catalog/store"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const providerSettingsManager = {
|
||||
getFilePath: vi.fn(() => path.join(tempDir, "settings", "providers.json")),
|
||||
getLastUsedProviderSettings: vi.fn(() => undefined),
|
||||
getProviderSettings: vi.fn((_providerId?: string) => undefined),
|
||||
saveProviderSettings: vi.fn(),
|
||||
@@ -39,6 +45,9 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
return undefined
|
||||
}),
|
||||
setGlobalStateBatch: vi.fn(),
|
||||
setGlobalState: vi.fn(),
|
||||
setSecret: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -72,11 +81,14 @@ vi.mock("@shared/services/Logger", () => ({
|
||||
|
||||
let tempDir: string
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
const previousDataDir = process.env.CLINE_DATA_DIR
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
|
||||
process.env.CLINE_DATA_DIR = tempDir
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
|
||||
vi.clearAllMocks()
|
||||
LlmsModels.resetRegistry()
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
actModeApiModelId: "claude-sonnet-4-6",
|
||||
@@ -88,12 +100,14 @@ beforeEach(() => {
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
mocks.providerSettingsManager.getFilePath.mockReturnValue(path.join(tempDir, "settings", "providers.json"))
|
||||
mocks.providerSettingsManager.getLastUsedProviderSettings.mockReturnValue(undefined)
|
||||
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
|
||||
process.env.CLINE_DATA_DIR = previousDataDir
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -135,6 +149,11 @@ describe("getDefaultModelIdForProvider", () => {
|
||||
expect(getDefaultModelIdForProvider("unknown-provider")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns no default for local-model-source providers so a cloud-catalog model is never silently selected", () => {
|
||||
expect(getDefaultModelIdForProvider("ollama")).toBeUndefined()
|
||||
expect(getDefaultModelIdForProvider("lmstudio")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves the OpenAI Compatible default through the extension's openai alias", () => {
|
||||
// The extension stores the OpenAI Compatible provider as "openai" while
|
||||
// the SDK catalog keys it as "openai-compatible". toSdkProviderId bridges
|
||||
@@ -230,9 +249,11 @@ describe("normalizeSdkBaseUrl", () => {
|
||||
expect(normalizeSdkBaseUrl("openai-compatible", " ")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("uses provider catalog defaults to add the SDK endpoint path when the user supplies only an origin", () => {
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434/v1")
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/v1")
|
||||
it("passes Ollama origins through unchanged (the native-API vendor appends /api itself)", () => {
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434")
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/")
|
||||
// Legacy 4.0.x configs may carry the OpenAI-compat /v1 suffix; it is
|
||||
// preserved here and rewritten to /api by the vendor.
|
||||
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/v1")).toBe("http://localhost:11434/v1")
|
||||
})
|
||||
|
||||
@@ -429,6 +450,40 @@ describe("buildSessionConfig", () => {
|
||||
expect(config.providerConfig).not.toHaveProperty("apiKey")
|
||||
})
|
||||
|
||||
it("preserves rich SDK catalog entries without extension-side replacement", async () => {
|
||||
const expectedModel = structuredClone((await LlmsModels.getModelsForProvider("anthropic"))["claude-sonnet-4-6"])
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
actModeApiModelId: "claude-sonnet-4-6",
|
||||
apiKey: "anthropic-key",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
const knownModel = (config.providerConfig as any).knownModels["claude-sonnet-4-6"]
|
||||
|
||||
expect(knownModel).toEqual(expectedModel)
|
||||
expect(knownModel.capabilities).toEqual(
|
||||
expect.arrayContaining(["images", "files", "tools", "reasoning", "structured_output", "temperature", "prompt-cache"]),
|
||||
)
|
||||
expect(knownModel.pricing).toEqual(expectedModel.pricing)
|
||||
expect(knownModel.releaseDate).toBe(expectedModel.releaseDate)
|
||||
expect(knownModel.family).toBe(expectedModel.family)
|
||||
})
|
||||
|
||||
it("keeps session creation non-fatal when known-model lookup fails", async () => {
|
||||
const lookupError = new Error("registry unavailable")
|
||||
const getModelsSpy = vi.spyOn(LlmsModels, "getModelsForProvider").mockRejectedValueOnce(lookupError)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerConfig).not.toHaveProperty("knownModels")
|
||||
expect(Logger.warn).toHaveBeenCalledWith(
|
||||
"[SessionFactory] Failed to resolve known models for provider=anthropic:",
|
||||
lookupError,
|
||||
)
|
||||
getModelsSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("passes OpenAI Compatible max output tokens as an explicit request limit", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openai",
|
||||
@@ -451,11 +506,87 @@ describe("buildSessionConfig", () => {
|
||||
expect(config.providerId).toBe("openai-compatible")
|
||||
expect(config.modelId).toBe("custom-reasoner")
|
||||
expect(config.knownModels).toBeUndefined()
|
||||
expect((config.providerConfig as any).knownModels).toBeUndefined()
|
||||
expect((config.providerConfig as any).knownModels).toBeDefined()
|
||||
expect((config.providerConfig as any).maxOutputTokens).toBeUndefined()
|
||||
expect((config as any).maxTokensPerTurn).toBe(4_096)
|
||||
})
|
||||
|
||||
it("uses OpenAI Compatible overrides from models.json for runtime request settings", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "custom-reasoner",
|
||||
openAiApiKey: "openai-compatible-key",
|
||||
openAiBaseUrl: "https://openai-compatible.example/v1",
|
||||
actModeOpenAiModelInfo: { supportsPromptCache: false },
|
||||
} as any)
|
||||
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
|
||||
providerId: parseProviderId("openai"),
|
||||
modelId: "custom-reasoner",
|
||||
overrides: {
|
||||
name: "Custom Reasoner",
|
||||
contextWindow: 16_000,
|
||||
maxInputTokens: 15_000,
|
||||
maxTokens: 1_234,
|
||||
capabilities: ["images", "reasoning", "streaming", "tools"],
|
||||
supportsVision: false,
|
||||
supportsAttachments: true,
|
||||
supportsReasoning: false,
|
||||
temperature: 0,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.1,
|
||||
cacheWritesPrice: 0.5,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
|
||||
|
||||
expect(config.providerId).toBe("openai-compatible")
|
||||
expect(config.modelId).toBe("custom-reasoner")
|
||||
expect((config as any).maxTokensPerTurn).toBe(1_234)
|
||||
expect((config as any).temperature).toBe(0)
|
||||
expect(knownModel).toMatchObject({
|
||||
id: "custom-reasoner",
|
||||
name: "Custom Reasoner",
|
||||
contextWindow: 16_000,
|
||||
maxInputTokens: 15_000,
|
||||
maxTokens: 1_234,
|
||||
capabilities: ["streaming", "tools", "files"],
|
||||
apiFormat: "openai-responses",
|
||||
temperature: 0,
|
||||
pricing: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.5 },
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps -1 OpenAI Compatible values out of request settings and fallback knownModels", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "custom-reasoner",
|
||||
openAiApiKey: "openai-compatible-key",
|
||||
openAiBaseUrl: "https://openai-compatible.example/v1",
|
||||
actModeOpenAiModelInfo: { supportsPromptCache: false },
|
||||
} as any)
|
||||
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
|
||||
providerId: parseProviderId("openai"),
|
||||
modelId: "custom-reasoner",
|
||||
overrides: {
|
||||
name: "Custom Reasoner",
|
||||
maxTokens: -1,
|
||||
temperature: -1,
|
||||
},
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect((config as any).maxTokensPerTurn).toBeUndefined()
|
||||
expect((config as any).temperature).toBeUndefined()
|
||||
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
|
||||
expect(knownModel).not.toHaveProperty("maxTokens")
|
||||
expect(knownModel).not.toHaveProperty("temperature", -1)
|
||||
})
|
||||
|
||||
it("passes OCA reasoning effort from legacy mode settings to SDK sessions", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "oca",
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
resolveProviderApiKeyFromSettings,
|
||||
type StartSessionResult,
|
||||
} from "@cline/core"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import type { ModelInfo as SdkModelInfo } from "@cline/llms"
|
||||
import {
|
||||
getGeneratedModelsForProvider,
|
||||
getModelsForProvider,
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
} from "@cline/llms"
|
||||
import { buildClineSystemPrompt } from "@cline/shared"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { ClineClient } from "@shared/cline"
|
||||
@@ -37,7 +43,11 @@ import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
|
||||
import { buildAgentHooks } from "./hooks-adapter"
|
||||
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
|
||||
import type { ResolvedModelSelection } from "./model-catalog/contracts"
|
||||
import { nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-catalog/model-values"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
|
||||
import { createProviderConfigStore, resolveRuntimeModelSelection } from "./model-catalog/store"
|
||||
import { getProviderSettingsManager } from "./provider-migration"
|
||||
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
@@ -209,8 +219,73 @@ function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | und
|
||||
|
||||
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
|
||||
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
|
||||
const maxTokens = modelInfo?.maxTokens
|
||||
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
|
||||
return positiveFiniteNumber(modelInfo?.maxTokens)
|
||||
}
|
||||
|
||||
function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
|
||||
const modelInfo = selection.modelInfo
|
||||
const capabilities = new Set<NonNullable<SdkModelInfo["capabilities"]>[number]>(
|
||||
(selection.overrides?.capabilities ?? []) as NonNullable<SdkModelInfo["capabilities"]>,
|
||||
)
|
||||
const setCapability = (capability: NonNullable<SdkModelInfo["capabilities"]>[number], enabled: boolean): void => {
|
||||
if (enabled) capabilities.add(capability)
|
||||
else capabilities.delete(capability)
|
||||
}
|
||||
if (modelInfo.supportsImages !== undefined) setCapability("images", modelInfo.supportsImages)
|
||||
setCapability("prompt-cache", modelInfo.supportsPromptCache)
|
||||
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
|
||||
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
|
||||
|
||||
const maxTokens = positiveFiniteNumber(modelInfo.maxTokens)
|
||||
const contextWindow = positiveFiniteNumber(modelInfo.contextWindow)
|
||||
const maxInputTokens = positiveFiniteNumber(selection.overrides?.maxInputTokens)
|
||||
const temperature = nonNegativeFiniteNumber(modelInfo.temperature)
|
||||
const inputPrice = nonNegativeFiniteNumber(modelInfo.inputPrice)
|
||||
const outputPrice = nonNegativeFiniteNumber(modelInfo.outputPrice)
|
||||
const cacheRead = nonNegativeFiniteNumber(modelInfo.cacheReadsPrice)
|
||||
const cacheWrite = nonNegativeFiniteNumber(modelInfo.cacheWritesPrice)
|
||||
const apiFormat = toSdkApiFormat(modelInfo.apiFormat)
|
||||
const hasPricing =
|
||||
inputPrice !== undefined || outputPrice !== undefined || cacheRead !== undefined || cacheWrite !== undefined
|
||||
|
||||
return {
|
||||
id: selection.modelId,
|
||||
name: modelInfo.name ?? selection.modelId,
|
||||
...(maxTokens !== undefined ? { maxTokens } : {}),
|
||||
...(contextWindow !== undefined ? { contextWindow } : {}),
|
||||
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
|
||||
...(capabilities.size > 0 ? { capabilities: [...capabilities] } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(hasPricing
|
||||
? {
|
||||
pricing: {
|
||||
...(inputPrice !== undefined ? { input: inputPrice } : {}),
|
||||
...(outputPrice !== undefined ? { output: outputPrice } : {}),
|
||||
...(cacheRead !== undefined ? { cacheRead } : {}),
|
||||
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCommittedRuntimeModel(
|
||||
providerId: string,
|
||||
mode: Mode,
|
||||
modelId: string | undefined,
|
||||
): ResolvedModelSelection | undefined {
|
||||
if (!modelId) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsedProviderId = parseProviderId(providerId)
|
||||
const selection = createProviderConfigStore().readSelection(parsedProviderId, mode)
|
||||
return selection?.modelId === modelId ? selection : resolveRuntimeModelSelection(parsedProviderId, modelId)
|
||||
} catch (error) {
|
||||
Logger.warn(`[SessionFactory] Failed to resolve committed model settings for provider=${providerId}:`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,8 +378,21 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
|
||||
|
||||
const DEFAULT_PROVIDER_ID = "cline"
|
||||
|
||||
/**
|
||||
* Providers whose model list comes from a live local endpoint (Ollama's
|
||||
* `/api/tags`, LM Studio's `/v1/models`). Their installed models are the only
|
||||
* meaningful catalog; a bundled-catalog default would silently select a model
|
||||
* the user never installed (e.g. an Ollama Cloud nemotron model).
|
||||
*/
|
||||
function providerHasLocalModelSource(providerId: string): boolean {
|
||||
return Boolean(MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.modelsSourceUrl)
|
||||
}
|
||||
|
||||
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
if (providerHasLocalModelSource(providerId)) {
|
||||
return undefined
|
||||
}
|
||||
const collection = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]
|
||||
if (!collection) {
|
||||
return undefined
|
||||
@@ -479,6 +567,42 @@ export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<Prov
|
||||
}
|
||||
}
|
||||
|
||||
type OllamaProviderConfig = {
|
||||
modelInfo?: { id: string; name: string; contextWindow: number }
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's "Model Context Window" setting for Ollama and surface it
|
||||
* as the selected model's `contextWindow`. The gateway carries it on the
|
||||
* resolved model definition, and the Ollama vendor maps it onto the wire as
|
||||
* `options.num_ctx` — without it Ollama loads every model with its 4096-token
|
||||
* server default. Keeping it on the model also means context management
|
||||
* budgets against the window Ollama actually applies (Ollama truncates the
|
||||
* prompt to `num_ctx` server-side).
|
||||
*/
|
||||
export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: string | undefined): OllamaProviderConfig {
|
||||
// providers.json (`contextWindow`) is the source of truth; the legacy
|
||||
// StateManager string is a migration fallback (the config store mirrors
|
||||
// writes to both).
|
||||
let settingsContextWindow: number | undefined
|
||||
try {
|
||||
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
|
||||
settingsContextWindow = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
|
||||
} catch {
|
||||
Logger.warn("[SessionFactory] Failed to read Ollama settings from providers.json")
|
||||
}
|
||||
const raw = config.ollamaApiOptionsCtxNum?.trim()
|
||||
const parsed = raw ? Number(raw) : Number.NaN
|
||||
const legacyContextWindow = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined
|
||||
const contextWindow = settingsContextWindow ?? legacyContextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW
|
||||
const timeoutMs = config.requestTimeoutMs
|
||||
return {
|
||||
...(typeof timeoutMs === "number" && timeoutMs > 0 ? { timeoutMs } : {}),
|
||||
...(modelId ? { modelInfo: { id: modelId, name: modelId, contextWindow } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
|
||||
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
|
||||
anthropic: "anthropicBaseUrl",
|
||||
@@ -536,6 +660,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
let bedrockProviderConfig: BedrockProviderConfig | undefined
|
||||
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
|
||||
let sapProviderConfig: SapProviderConfig | undefined
|
||||
let ollamaProviderConfig: ReturnType<typeof resolveOllamaProviderConfig> | undefined
|
||||
|
||||
try {
|
||||
const stateManager = StateManager.get()
|
||||
@@ -571,6 +696,10 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
baseUrl = sapProviderConfig.baseUrl
|
||||
}
|
||||
|
||||
if (providerId === "ollama") {
|
||||
ollamaProviderConfig = resolveOllamaProviderConfig(apiConfig, modelId)
|
||||
}
|
||||
|
||||
Logger.log(
|
||||
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
|
||||
)
|
||||
@@ -606,12 +735,31 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
// Final defaults. Keep this aligned with the provider catalog so the UI and
|
||||
// session factory share one source of truth for default models.
|
||||
providerId = providerId ?? DEFAULT_PROVIDER_ID
|
||||
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
|
||||
if (!modelId && providerHasLocalModelSource(providerId)) {
|
||||
// Local-model-source providers: the committed selection lives in
|
||||
// providers.json when the legacy state slot is empty (e.g. configs
|
||||
// created through the SDK settings store). Never fall through to a
|
||||
// catalog default — an empty model id surfaces an explicit "select a
|
||||
// model" state instead of silently running a model the user never chose.
|
||||
try {
|
||||
modelId = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))?.model?.trim()
|
||||
} catch {
|
||||
Logger.warn(`[SessionFactory] Failed to read ${providerId} model from providers.json`)
|
||||
}
|
||||
modelId = modelId || ""
|
||||
} else {
|
||||
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
|
||||
}
|
||||
if (!apiKey && apiConfig) {
|
||||
apiKey = resolveApiKey(providerId, apiConfig)
|
||||
}
|
||||
apiKey = apiKey ?? ""
|
||||
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
|
||||
const committedRuntimeModel = resolveCommittedRuntimeModel(providerId, mode, modelId)
|
||||
const overriddenMaxTokens = committedRuntimeModel?.overrides?.maxTokens
|
||||
const maxTokensPerTurn =
|
||||
positiveFiniteNumber(overriddenMaxTokens) ??
|
||||
(providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined)
|
||||
const temperature = nonNegativeFiniteNumber(committedRuntimeModel?.overrides?.temperature)
|
||||
const reasoningConfig =
|
||||
providerId === "oca"
|
||||
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
|
||||
@@ -662,13 +810,34 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const hostIdentity = await resolveHostIdentity()
|
||||
const isMultiRoot = await resolveIsMultiRootWorkspace()
|
||||
let knownModels: Awaited<ReturnType<typeof getModelsForProvider>> | undefined
|
||||
try {
|
||||
// Constructing the settings manager loads providers.json and models.json into
|
||||
// the @cline/llms registry. Reading models from that registry ensures custom
|
||||
// model overrides are included in the inference provider config, not just in
|
||||
// the webview/display path.
|
||||
getProviderSettingsManager(resolveDataDir())
|
||||
knownModels = await getModelsForProvider(sdkProviderId)
|
||||
// Only inject host-resolved metadata that carries real information
|
||||
// (catalog/state base or user overrides). Pure fallback fabrications
|
||||
// must not reach the runtime; the SDK's own resolution handles those.
|
||||
const isPureFallbackModel = committedRuntimeModel?.modelInfoSource === "fallback" && !committedRuntimeModel.overrides
|
||||
if (committedRuntimeModel && !isPureFallbackModel && !knownModels?.[modelId]) {
|
||||
knownModels = {
|
||||
...(knownModels ?? {}),
|
||||
[modelId]: toSdkModelInfo(committedRuntimeModel),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(`[SessionFactory] Failed to resolve known models for provider=${sdkProviderId}:`, error)
|
||||
}
|
||||
|
||||
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
|
||||
// gateway; without it the agent loop uses bare global fetch and corporate
|
||||
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
|
||||
// additionally need structured options (region/project/auth/SAP OAuth), which core
|
||||
// reads from providerConfig in createAgentModelFromConfig.
|
||||
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
|
||||
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig ?? ollamaProviderConfig
|
||||
// Spread the cloud config first so the explicit fields below — notably the
|
||||
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
|
||||
const providerConfig = {
|
||||
@@ -677,6 +846,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
modelId,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(baseUrl !== undefined ? { baseUrl } : {}),
|
||||
...(knownModels && Object.keys(knownModels).length > 0 ? { knownModels } : {}),
|
||||
fetch,
|
||||
}
|
||||
|
||||
@@ -707,6 +877,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
mode: mode === "plan" ? "plan" : "act",
|
||||
...reasoningConfig,
|
||||
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
maxIterations: undefined,
|
||||
logger: sdkLogger,
|
||||
extensionContext: {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type {
|
||||
EffectiveProviderConfig,
|
||||
Fingerprint,
|
||||
ModelSelection,
|
||||
ProviderConfigChange,
|
||||
ProviderConfigReader,
|
||||
ProviderModelsResult,
|
||||
ResolvedModelSelection,
|
||||
} from "./contracts"
|
||||
import { computeConfigFingerprint } from "./fingerprint"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
@@ -95,7 +95,7 @@ function record(
|
||||
}
|
||||
}
|
||||
|
||||
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ModelSelection): TestReader {
|
||||
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ResolvedModelSelection): TestReader {
|
||||
let config = initialConfig
|
||||
const listeners = new Set<(event: ProviderConfigChange) => void>()
|
||||
return {
|
||||
@@ -241,7 +241,7 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const config: EffectiveProviderConfig = { providerId, apiKey: "secret", baseUrl: "https://provider.example.com" }
|
||||
const selection: ModelSelection = { providerId, modelId: "selected", modelInfo }
|
||||
const selection: ResolvedModelSelection = { providerId, modelId: "selected", modelInfo }
|
||||
const reader = makeReader(config, selection)
|
||||
const catalog = createProviderCatalog(reader)
|
||||
|
||||
@@ -507,7 +507,7 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const reader = makeReader({ providerId, apiKey: "same" })
|
||||
const catalog = createProviderCatalog(reader)
|
||||
const selection: ModelSelection = { providerId, modelId: "different", modelInfo }
|
||||
const selection: ResolvedModelSelection = { providerId, modelId: "different", modelInfo }
|
||||
|
||||
const first = await catalog.resolveModels(providerId)
|
||||
reader.emit({ kind: "selection", providerId, mode: "act", selection })
|
||||
@@ -692,7 +692,7 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
|
||||
const catalog = createProviderCatalog(reader)
|
||||
const listener = vi.fn()
|
||||
const selection: ModelSelection = { providerId, modelId: "custom:latest", modelInfo }
|
||||
const selection: ResolvedModelSelection = { providerId, modelId: "custom:latest", modelInfo }
|
||||
catalog.subscribe(providerId, listener)
|
||||
|
||||
reader.emit({ kind: "selection", providerId, mode: "act", selection })
|
||||
|
||||
@@ -68,8 +68,8 @@ export type Fingerprint = string & { readonly [FingerprintBrand]: void }
|
||||
* - Two reads with no intervening write return structurally equal values.
|
||||
* - Consumers must not mutate. The shape is `Readonly`.
|
||||
*
|
||||
* Mode-dependent selection (modelId, modelInfo) is *not* part of this
|
||||
* type. Use `ProviderConfigStore.readSelection(providerId, mode)`.
|
||||
* Mode-dependent selection is *not* part of this type. Use
|
||||
* `ProviderConfigStore.readSelection(providerId, mode)`.
|
||||
*/
|
||||
export interface AwsProviderConfig {
|
||||
readonly accessKey?: string
|
||||
@@ -98,6 +98,12 @@ export interface EffectiveProviderConfig {
|
||||
readonly region?: string
|
||||
readonly aws?: AwsProviderConfig
|
||||
readonly gcp?: GcpProviderConfig
|
||||
/**
|
||||
* Provider-level context window (providers.json `contextWindow`).
|
||||
* Provider-neutral: for Ollama it maps to `options.num_ctx` at the
|
||||
* vendor boundary.
|
||||
*/
|
||||
readonly contextWindow?: number
|
||||
/**
|
||||
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
|
||||
* Compatible with `apiKey`; some providers populate both.
|
||||
@@ -119,9 +125,9 @@ export interface EffectiveProviderConfig {
|
||||
* A patch describing a field-level write to `ProviderConfigStore`.
|
||||
*
|
||||
* Invariant: `ProviderConfigPatch` cannot describe a model selection. The
|
||||
* type does not contain `modelId` or `modelInfo`. To write a selection,
|
||||
* use `commitSelection`, which is a structurally distinct method on the
|
||||
* store.
|
||||
* type does not contain `modelId` or per-model overrides. To write a
|
||||
* selection, use `commitSelection`, which is a structurally distinct method
|
||||
* on the store.
|
||||
*
|
||||
* Empty patches are allowed and are no-ops. A field present with value
|
||||
* `null` means "clear this field"; an absent field means "leave unchanged."
|
||||
@@ -140,6 +146,7 @@ export interface ProviderConfigPatch {
|
||||
readonly region?: string | null
|
||||
readonly aws?: AwsProviderConfig | null
|
||||
readonly gcp?: GcpProviderConfig | null
|
||||
readonly contextWindow?: number | null
|
||||
readonly auth?: {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
@@ -153,22 +160,65 @@ export interface ProviderConfigPatch {
|
||||
// Model selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-model metadata overrides authored by the user for custom models. */
|
||||
export interface ModelSelectionOverrides {
|
||||
readonly name?: string
|
||||
readonly maxTokens?: number
|
||||
readonly contextWindow?: number
|
||||
readonly maxInputTokens?: number
|
||||
readonly capabilities?: readonly string[]
|
||||
readonly supportsVision?: boolean
|
||||
readonly supportsAttachments?: boolean
|
||||
readonly supportsReasoning?: boolean
|
||||
readonly inputPrice?: number
|
||||
readonly outputPrice?: number
|
||||
readonly cacheReadsPrice?: number
|
||||
readonly cacheWritesPrice?: number
|
||||
readonly temperature?: number
|
||||
readonly apiFormat?: ModelInfo["apiFormat"]
|
||||
readonly isR1FormatRequired?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's committed model selection. The triple is atomic by type: every
|
||||
* write of `modelId` carries its `modelInfo` envelope, and vice versa.
|
||||
* A user's committed model selection. The committed write stores only the
|
||||
* selected model id plus optional user-authored overrides. `ModelInfo` is
|
||||
* derived by the host from the SDK catalog, then layered with `models.json`
|
||||
* overrides, then per-provider safe fallback metadata.
|
||||
*
|
||||
* Invariants:
|
||||
* - `modelInfo` was either taken from a `ProviderCatalog.resolveModels`
|
||||
* result, or constructed from per-provider safe defaults when the user
|
||||
* entered a custom id manually. Either way it represents the picker's
|
||||
* best knowledge at the moment of commit. The runtime uses it verbatim.
|
||||
* - The stored selection wins over later SDK catalog changes. Refresh
|
||||
* does not retroactively change committed selections.
|
||||
* - The webview does not commit a `ModelInfo` snapshot.
|
||||
* - Catalog metadata updates may affect the resolved `ModelInfo` for an
|
||||
* existing selection unless the user has explicitly overridden the field.
|
||||
*/
|
||||
export interface ModelSelection {
|
||||
readonly providerId: ProviderId
|
||||
readonly modelId: string
|
||||
readonly overrides?: ModelSelectionOverrides
|
||||
}
|
||||
|
||||
/** A committed selection as read back by consumers that need display/runtime metadata. */
|
||||
export interface ResolvedModelSelection extends ModelSelection {
|
||||
readonly modelInfo: ModelInfo
|
||||
/**
|
||||
* Where the base `modelInfo` came from, before overrides were applied:
|
||||
*
|
||||
* - "catalog" — SDK catalog metadata (generated snapshot or registry).
|
||||
* - "state" — the mode-specific `*ModeModelInfo` snapshot persisted by
|
||||
* the picker at selection time. Authoritative for dynamic-list providers
|
||||
* (openrouter, litellm, requesty, …) whose models are not in the static
|
||||
* catalog.
|
||||
* - "fallback" — provider-safe defaults fabricated because nothing better
|
||||
* was available. Consumers should treat a "fallback" resolution without
|
||||
* overrides as weak data and prefer live catalog lookups over it.
|
||||
*/
|
||||
readonly modelInfoSource?: "catalog" | "state" | "fallback"
|
||||
/**
|
||||
* The base metadata `modelInfo` was resolved from, before overrides were
|
||||
* applied. Persisted (rather than the resolved value) into the legacy
|
||||
* state snapshot so that deleting an override cannot resurrect it from a
|
||||
* snapshot it was previously baked into.
|
||||
*/
|
||||
readonly baseModelInfo?: ModelInfo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -190,7 +240,7 @@ export type ProviderConfigChange =
|
||||
readonly kind: "selection"
|
||||
readonly providerId: ProviderId
|
||||
readonly mode: Mode
|
||||
readonly selection: ModelSelection
|
||||
readonly selection: ResolvedModelSelection
|
||||
}
|
||||
|
||||
export type ProviderConfigChangeListener = (event: ProviderConfigChange) => void
|
||||
@@ -317,7 +367,7 @@ export interface ProviderModelsEvent {
|
||||
*/
|
||||
export interface ProviderConfigReader {
|
||||
read(providerId: ProviderId): EffectiveProviderConfig
|
||||
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined
|
||||
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined
|
||||
subscribe(listener: ProviderConfigChangeListener): Disposable
|
||||
}
|
||||
|
||||
@@ -351,8 +401,9 @@ export interface ProviderConfigStore extends ProviderConfigReader {
|
||||
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig
|
||||
|
||||
/**
|
||||
* Commit a model selection atomically with its info envelope. The only
|
||||
* entry point that writes `{providerId, modelId, modelInfo}` triples.
|
||||
* Commit a model ID atomically with optional user-authored overrides.
|
||||
* Supplying overrides replaces that model's stored override entry; omitting
|
||||
* them leaves the existing entry unchanged.
|
||||
*
|
||||
* I2: refresh handlers do not have access to this method by type, since
|
||||
* `ProviderCatalog` holds only a `ProviderConfigReader`.
|
||||
|
||||
@@ -54,7 +54,27 @@ describe("buildEffectiveProviderConfig", () => {
|
||||
providerId: parseProviderId("ollama"),
|
||||
apiKey: "provider-ollama-key",
|
||||
baseUrl: "http://state-ollama:11434",
|
||||
extras: { ollamaApiOptionsCtxNum: "8192" },
|
||||
// The legacy state string surfaces as the provider-neutral
|
||||
// contextWindow when providers.json has none.
|
||||
contextWindow: 8192,
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers the providers.json contextWindow over the legacy Ollama state key", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
ollama: {
|
||||
provider: "ollama",
|
||||
contextWindow: 65536,
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
ollamaApiOptionsCtxNum: "8192",
|
||||
})
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
|
||||
providerId: parseProviderId("ollama"),
|
||||
contextWindow: 65536,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type ProviderSettingsLike = {
|
||||
readonly region?: string
|
||||
readonly aws?: AwsProviderConfig
|
||||
readonly gcp?: GcpProviderConfig
|
||||
readonly contextWindow?: number
|
||||
readonly auth?: AuthConfig
|
||||
readonly extras?: ExtrasConfig
|
||||
}
|
||||
@@ -102,7 +103,6 @@ const headerFields: Partial<Record<string, keyof ApiConfiguration>> = {
|
||||
}
|
||||
|
||||
const extrasFields: Partial<Record<string, Partial<Record<string, keyof ApiConfiguration>>>> = {
|
||||
ollama: { ollamaApiOptionsCtxNum: "ollamaApiOptionsCtxNum" },
|
||||
lmstudio: { lmStudioMaxTokens: "lmStudioMaxTokens" },
|
||||
litellm: { liteLlmUsePromptCache: "liteLlmUsePromptCache" },
|
||||
openrouter: { openRouterProviderSorting: "openRouterProviderSorting" },
|
||||
@@ -157,6 +157,14 @@ function readBoolean(record: Record<string, unknown>, key: string): boolean | un
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
const parsed = typeof value === "string" ? Number(value) : value
|
||||
if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined {
|
||||
const gcp = record.gcp
|
||||
if (!isPlainRecord(gcp)) {
|
||||
@@ -206,6 +214,7 @@ function readProviderSettings(providerId: ProviderId): ConfigParts {
|
||||
region: readString(settings, "region"),
|
||||
aws: readAws(settings),
|
||||
gcp: readGcp(settings),
|
||||
contextWindow: readPositiveInteger(settings.contextWindow),
|
||||
auth: readAuth(settings),
|
||||
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
|
||||
} satisfies ProviderSettingsLike
|
||||
@@ -298,6 +307,16 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
|
||||
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
|
||||
}
|
||||
|
||||
function readStateContextWindow(provider: string, config: ApiConfiguration): number | undefined {
|
||||
// Only Ollama has a legacy context-window state key; other providers keep
|
||||
// theirs in providers.json exclusively.
|
||||
if (provider !== "ollama") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return readPositiveInteger(config.ollamaApiOptionsCtxNum)
|
||||
}
|
||||
|
||||
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
|
||||
const provider = providerId.toString()
|
||||
return {
|
||||
@@ -308,6 +327,7 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
|
||||
region: readStringFromConfig(config, regionFields[provider]),
|
||||
aws: readStateAws(provider, config),
|
||||
gcp: readStateGcp(provider, config),
|
||||
contextWindow: readStateContextWindow(provider, config),
|
||||
auth: readStateAuth(provider, config),
|
||||
extras: readStateExtras(provider, config),
|
||||
}
|
||||
@@ -372,6 +392,10 @@ export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveP
|
||||
// fields as a fallback for old installs, but let providers.json win when both exist.
|
||||
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
|
||||
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
|
||||
// providers.json is the source of truth for the context window; the legacy
|
||||
// Ollama StateManager key is a migration fallback (the store mirrors writes
|
||||
// to both).
|
||||
assignIfDefined(merged, "contextWindow", providerSettings.contextWindow ?? stateConfig.contextWindow)
|
||||
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
|
||||
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
|
||||
|
||||
|
||||
@@ -170,6 +170,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
|
||||
region: config.region ?? null,
|
||||
aws: sanitizeAws(config.aws),
|
||||
gcp: sanitizeGcp(config.gcp),
|
||||
// The context window is not a secret; include it raw so changes
|
||||
// invalidate cached model lists.
|
||||
contextWindow: config.contextWindow ?? null,
|
||||
extras: sanitizeExtras(config.extras),
|
||||
auth: {
|
||||
accountId: config.auth?.accountId ?? null,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Applies the `ModelInfo` fields the extension owns locally, on top of
|
||||
* an adapted SDK `ModelInfo`. Today this is just Vertex's
|
||||
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`).
|
||||
* an adapted SDK `ModelInfo`. Today this is Vertex's
|
||||
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`)
|
||||
* and Ollama's effective context window.
|
||||
*
|
||||
* Both the model-list resolution path (`resolveSdkModels`) and the
|
||||
* single-model lookup path (`resolveModelInfo`) pass adapted
|
||||
@@ -10,13 +11,52 @@
|
||||
* flags upstream, the override and this file can be removed together.
|
||||
*/
|
||||
|
||||
import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "@cline/llms"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getProviderSettingsManager } from "../provider-migration"
|
||||
import type { ProviderId } from "./contracts"
|
||||
import { vertexModelSupportsGlobalEndpoint } from "./vertex-global-endpoint"
|
||||
|
||||
/**
|
||||
* The context window Ollama actually applies is the requested `num_ctx`,
|
||||
* not the model's native maximum — Ollama truncates the prompt to it
|
||||
* server-side. Surface the user's "Model Context Window" setting (or the
|
||||
* request default) instead of catalog/safe-default values so the chat
|
||||
* indicator and context management match reality.
|
||||
*/
|
||||
function resolveOllamaContextWindow(): number {
|
||||
// providers.json (`contextWindow`) is the source of truth; the legacy
|
||||
// StateManager string is a migration fallback (the config store mirrors
|
||||
// writes to both).
|
||||
try {
|
||||
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
} catch {
|
||||
// providers.json unavailable — fall through to the legacy state key.
|
||||
}
|
||||
try {
|
||||
const raw = StateManager.get().getApiConfiguration().ollamaApiOptionsCtxNum?.trim()
|
||||
if (raw) {
|
||||
const value = Number(raw)
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// StateManager unavailable (e.g. tests) — fall through to the default.
|
||||
}
|
||||
return OLLAMA_DEFAULT_CONTEXT_WINDOW
|
||||
}
|
||||
|
||||
export function applyHostModelInfoOverrides(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): ModelInfo {
|
||||
if (providerId === "vertex" && vertexModelSupportsGlobalEndpoint(providerId, modelId)) {
|
||||
return { ...modelInfo, supportsGlobalEndpoint: true }
|
||||
}
|
||||
if (providerId === "ollama") {
|
||||
return { ...modelInfo, contextWindow: resolveOllamaContextWindow() }
|
||||
}
|
||||
return modelInfo
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
|
||||
/** SDK string spelling of an API format (matches @cline/shared ApiFormatSchema). */
|
||||
export type SdkApiFormatString = "r1" | "openai-responses" | "default"
|
||||
|
||||
export function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
export function positiveFiniteNumber(value: unknown): number | undefined {
|
||||
const number = finiteNumber(value)
|
||||
return number !== undefined && number > 0 ? number : undefined
|
||||
}
|
||||
|
||||
export function nonNegativeFiniteNumber(value: unknown): number | undefined {
|
||||
const number = finiteNumber(value)
|
||||
return number !== undefined && number >= 0 ? number : undefined
|
||||
}
|
||||
|
||||
export function toSdkApiFormat(apiFormat: ModelInfo["apiFormat"]): SdkApiFormatString | undefined {
|
||||
switch (apiFormat) {
|
||||
case ApiFormat.R1_CHAT:
|
||||
return "r1"
|
||||
case ApiFormat.OPENAI_RESPONSES:
|
||||
return "openai-responses"
|
||||
case ApiFormat.OPENAI_CHAT:
|
||||
return "default"
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function fromSdkApiFormat(apiFormat: string | undefined): ModelInfo["apiFormat"] | undefined {
|
||||
switch (apiFormat) {
|
||||
case "r1":
|
||||
return ApiFormat.R1_CHAT
|
||||
case "openai-responses":
|
||||
return ApiFormat.OPENAI_RESPONSES
|
||||
case "default":
|
||||
return ApiFormat.OPENAI_CHAT
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import { syncStoredProviderRegistration } from "@cline/core"
|
||||
import { type ApiConfiguration, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type { ProviderConfigChange } from "./contracts"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
@@ -7,6 +9,11 @@ const mocks = vi.hoisted(() => {
|
||||
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
|
||||
let apiConfiguration: MockApiConfiguration = {}
|
||||
let providerSettingsById: Record<string, Record<string, unknown>> = {}
|
||||
let generatedModelsByProvider: Record<string, Record<string, ModelInfo>> = {}
|
||||
let modelsFile: { version: 1; providers: Record<string, { models?: Record<string, Record<string, unknown>> }> } = {
|
||||
version: 1,
|
||||
providers: {},
|
||||
}
|
||||
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
|
||||
const provider = settings.provider
|
||||
if (typeof provider !== "string") {
|
||||
@@ -20,6 +27,8 @@ const mocks = vi.hoisted(() => {
|
||||
reset(): void {
|
||||
apiConfiguration = {}
|
||||
providerSettingsById = {}
|
||||
generatedModelsByProvider = {}
|
||||
modelsFile = { version: 1, providers: {} }
|
||||
saveProviderSettings.mockClear()
|
||||
},
|
||||
setApiConfiguration(value: MockApiConfiguration): void {
|
||||
@@ -28,6 +37,12 @@ const mocks = vi.hoisted(() => {
|
||||
setProviderSettings(value: Record<string, Record<string, unknown>>): void {
|
||||
providerSettingsById = { ...value }
|
||||
},
|
||||
setGeneratedModels(providerId: string, models: Record<string, ModelInfo>): void {
|
||||
generatedModelsByProvider = { ...generatedModelsByProvider, [providerId]: models }
|
||||
},
|
||||
getGeneratedModels(providerId: string): Record<string, ModelInfo> {
|
||||
return generatedModelsByProvider[providerId] ?? {}
|
||||
},
|
||||
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
|
||||
return providerSettingsById[providerId]
|
||||
},
|
||||
@@ -37,6 +52,12 @@ const mocks = vi.hoisted(() => {
|
||||
getSaveProviderSettingsMock(): typeof saveProviderSettings {
|
||||
return saveProviderSettings
|
||||
},
|
||||
getModelsFile() {
|
||||
return modelsFile
|
||||
},
|
||||
setModelsFile(value: typeof modelsFile): void {
|
||||
modelsFile = value
|
||||
},
|
||||
getStateManager() {
|
||||
return {
|
||||
getApiConfiguration: () => ({ ...apiConfiguration }),
|
||||
@@ -69,11 +90,24 @@ vi.mock("../provider-migration", () => ({
|
||||
getProviderSettingsManager: mocks.getProviderSettingsManager,
|
||||
}))
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
syncStoredProviderRegistration: vi.fn(),
|
||||
readModelsFileSync: vi.fn(() => mocks.getModelsFile()),
|
||||
resolveModelsRegistryPath: vi.fn(() => "/tmp/models.json"),
|
||||
writeModelsFileSync: vi.fn((_filePath: string, state: ReturnType<typeof mocks.getModelsFile>) => mocks.setModelsFile(state)),
|
||||
}))
|
||||
|
||||
vi.mock("@cline/llms", () => ({
|
||||
getGeneratedModelsForProvider: vi.fn((providerId: string) => mocks.getGeneratedModels(providerId)),
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID: {},
|
||||
}))
|
||||
|
||||
const modelInfoA: ModelInfo = {
|
||||
name: "Model A",
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
}
|
||||
|
||||
const modelInfoB: ModelInfo = {
|
||||
@@ -83,9 +117,41 @@ const modelInfoB: ModelInfo = {
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
|
||||
function selectionFromModelInfo(providerId: ReturnType<typeof parseProviderId>, modelId: string, modelInfo: ModelInfo) {
|
||||
const capabilities: string[] = []
|
||||
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
|
||||
if (modelInfo.supportsImages) capabilities.push("images")
|
||||
if (modelInfo.supportsReasoning) capabilities.push("reasoning")
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
overrides: {
|
||||
name: modelInfo.name,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
...(modelInfo.apiFormat !== undefined ? { apiFormat: modelInfo.apiFormat } : {}),
|
||||
...(capabilities.length > 0 ? { capabilities } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function expectResolvedSelection(
|
||||
actual: unknown,
|
||||
selection: ReturnType<typeof selectionFromModelInfo>,
|
||||
modelInfo: ModelInfo,
|
||||
): void {
|
||||
expect(actual).toMatchObject({
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
overrides: selection.overrides,
|
||||
modelInfo,
|
||||
})
|
||||
}
|
||||
|
||||
describe("createProviderConfigStore", () => {
|
||||
beforeEach(() => {
|
||||
mocks.reset()
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
@@ -121,22 +187,29 @@ describe("createProviderConfigStore", () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const selection = { providerId, modelId: "anthropic/claude-sonnet-4", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "anthropic/claude-sonnet-4", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
expect(mocks.getModelsFile().providers.openrouter?.models?.["anthropic/claude-sonnet-4"]).toMatchObject({
|
||||
name: "Model A",
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
apiFormat: "openai-responses",
|
||||
capabilities: ["prompt-cache"],
|
||||
})
|
||||
})
|
||||
|
||||
it("round-trips generic provider selections using the in-process modelInfo envelope", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const selection = { providerId, modelId: "deepseek-v4-pro", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "deepseek-v4-pro", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
})
|
||||
|
||||
it("hydrates a generic provider selection from providers.json after reload", async () => {
|
||||
@@ -148,6 +221,8 @@ describe("createProviderConfigStore", () => {
|
||||
expect(store.readSelection(providerId, "act")).toEqual({
|
||||
providerId,
|
||||
modelId: "manual-zai-model",
|
||||
modelInfoSource: "fallback",
|
||||
baseModelInfo: expect.objectContaining({ name: "manual-zai-model" }),
|
||||
modelInfo: expect.objectContaining({
|
||||
name: "manual-zai-model",
|
||||
supportsPromptCache: false,
|
||||
@@ -160,27 +235,27 @@ describe("createProviderConfigStore", () => {
|
||||
const store = createProviderConfigStore()
|
||||
const geminiProviderId = parseProviderId("gemini")
|
||||
const deepSeekProviderId = parseProviderId("deepseek")
|
||||
const geminiSelection = { providerId: geminiProviderId, modelId: "gemini-3.1-pro-preview", modelInfo: modelInfoA }
|
||||
const deepSeekSelection = { providerId: deepSeekProviderId, modelId: "deepseek-v4-pro", modelInfo: modelInfoB }
|
||||
const geminiSelection = selectionFromModelInfo(geminiProviderId, "gemini-3.1-pro-preview", modelInfoA)
|
||||
const deepSeekSelection = selectionFromModelInfo(deepSeekProviderId, "deepseek-v4-pro", modelInfoB)
|
||||
|
||||
store.commitSelection(geminiProviderId, "act", geminiSelection)
|
||||
store.commitSelection(deepSeekProviderId, "act", deepSeekSelection)
|
||||
|
||||
expect(store.readSelection(geminiProviderId, "act")).toEqual(geminiSelection)
|
||||
expect(store.readSelection(deepSeekProviderId, "act")).toEqual(deepSeekSelection)
|
||||
expectResolvedSelection(store.readSelection(geminiProviderId, "act"), geminiSelection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(deepSeekProviderId, "act"), deepSeekSelection, modelInfoB)
|
||||
})
|
||||
|
||||
it("handles normalized nousResearch provider casing for writes and selections", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("nousResearch")
|
||||
const selection = { providerId, modelId: "nousresearch/hermes-4-70b", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "nousresearch/hermes-4-70b", modelInfoA)
|
||||
|
||||
const written = store.write(providerId, { apiKey: "nous-key" })
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(written).toEqual({ providerId, apiKey: "nous-key" })
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
|
||||
provider: "nousResearch",
|
||||
apiKey: "nous-key",
|
||||
@@ -227,6 +302,179 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("lazily migrates meaningful legacy custom-model metadata once", async () => {
|
||||
const legacyModelInfo = {
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.25,
|
||||
cacheWritesPrice: 0.5,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
isR1FormatRequired: true,
|
||||
}
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "legacy-custom",
|
||||
actModeOpenAiModelInfo: legacyModelInfo,
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const first = store.readSelection(providerId, "act")
|
||||
const second = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["legacy-custom"]).toEqual({
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
capabilities: ["prompt-cache"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.25,
|
||||
cacheWritesPrice: 0.5,
|
||||
temperature: 0.3,
|
||||
apiFormat: "openai-responses",
|
||||
isR1FormatRequired: true,
|
||||
})
|
||||
expect(first?.overrides).toEqual(second?.overrides)
|
||||
expect(first?.modelInfo).toMatchObject({
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.25,
|
||||
cacheWritesPrice: 0.5,
|
||||
temperature: 0.3,
|
||||
apiFormat: ApiFormat.R1_CHAT,
|
||||
})
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("does not create migration noise for legacy safe defaults", async () => {
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "default-custom",
|
||||
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, name: "default-custom" },
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const first = store.readSelection(providerId, "act")
|
||||
const second = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["default-custom"]).toBeUndefined()
|
||||
expect(first?.overrides).toBeUndefined()
|
||||
expect(second?.overrides).toBeUndefined()
|
||||
expect(first?.modelInfo).toMatchObject({ contextWindow: 128_000, supportsImages: true, temperature: 0 })
|
||||
expect(first?.modelInfo.maxTokens).toBeUndefined()
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("never overwrites an existing models.json entry during migration", async () => {
|
||||
mocks.setModelsFile({
|
||||
version: 1,
|
||||
providers: {
|
||||
"openai-compatible": { models: { "existing-custom": { temperature: 0.7 } } },
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "existing-custom",
|
||||
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, temperature: 0.2 },
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["existing-custom"]).toEqual({
|
||||
temperature: 0.7,
|
||||
})
|
||||
expect(selection?.overrides).toEqual({ temperature: 0.7 })
|
||||
expect(selection?.modelInfo.temperature).toBe(0.7)
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not migrate stale legacy snapshots for catalog-known models", async () => {
|
||||
mocks.setGeneratedModels("openai-compatible", {
|
||||
"known-model": {
|
||||
name: "Current Catalog Model",
|
||||
contextWindow: 256_000,
|
||||
supportsPromptCache: false,
|
||||
temperature: 0.1,
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
actModeOpenAiModelId: "known-model",
|
||||
actModeOpenAiModelInfo: {
|
||||
name: "Stale Catalog Model",
|
||||
contextWindow: 32_000,
|
||||
supportsPromptCache: false,
|
||||
temperature: 0.9,
|
||||
},
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["known-model"]).toBeUndefined()
|
||||
expect(selection?.overrides).toBeUndefined()
|
||||
expect(selection?.modelInfo).toMatchObject({
|
||||
name: "Current Catalog Model",
|
||||
contextWindow: 256_000,
|
||||
temperature: 0.1,
|
||||
})
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("migrates separate Plan and Act legacy custom models independently", async () => {
|
||||
mocks.setApiConfiguration({
|
||||
planActSeparateModelsSetting: true,
|
||||
planModeOpenAiModelId: "legacy-plan",
|
||||
planModeOpenAiModelInfo: {
|
||||
...openAiModelInfoSafeDefaults,
|
||||
contextWindow: 64_000,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
},
|
||||
actModeOpenAiModelId: "legacy-act",
|
||||
actModeOpenAiModelInfo: {
|
||||
...openAiModelInfoSafeDefaults,
|
||||
maxTokens: 2_048,
|
||||
isR1FormatRequired: true,
|
||||
},
|
||||
})
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const plan = store.readSelection(providerId, "plan")
|
||||
const act = store.readSelection(providerId, "act")
|
||||
store.readSelection(providerId, "plan")
|
||||
store.readSelection(providerId, "act")
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models).toMatchObject({
|
||||
"legacy-plan": { contextWindow: 64_000, apiFormat: "openai-responses" },
|
||||
"legacy-act": { maxTokens: 2_048, isR1FormatRequired: true },
|
||||
})
|
||||
expect(plan?.modelInfo).toMatchObject({ contextWindow: 64_000, apiFormat: ApiFormat.OPENAI_RESPONSES })
|
||||
expect(act?.modelInfo).toMatchObject({ maxTokens: 2_048, apiFormat: ApiFormat.R1_CHAT })
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setProviderSettings({
|
||||
@@ -237,7 +485,7 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "gpt-oss-120b", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
@@ -248,6 +496,242 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves per-model OpenAI Compatible overrides when switching models without new overrides", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const modelASelection = selectionFromModelInfo(providerId, "model-a", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", modelASelection)
|
||||
store.commitSelection(providerId, "act", { providerId, modelId: "model-b" })
|
||||
store.commitSelection(providerId, "act", { providerId, modelId: "model-a" })
|
||||
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), modelASelection, modelInfoA)
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["model-a"]).toMatchObject({
|
||||
name: "Model A",
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
})
|
||||
|
||||
it("deletes a model entry when an explicit replacement override set is empty", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
capabilities: ["tools", "streaming"],
|
||||
temperature: 0.2,
|
||||
},
|
||||
})
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toMatchObject({
|
||||
apiFormat: "openai-responses",
|
||||
capabilities: ["tools", "streaming"],
|
||||
temperature: 0.2,
|
||||
})
|
||||
|
||||
store.commitSelection(providerId, "act", { providerId, modelId: "custom-model", overrides: {} })
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
|
||||
})
|
||||
|
||||
it("replaces an existing model override set instead of merging stale fields", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { apiFormat: ApiFormat.OPENAI_RESPONSES, inputPrice: 1, temperature: 0.2 },
|
||||
})
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { temperature: 0.4 },
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({ temperature: 0.4 })
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ temperature: 0.4 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[ApiFormat.OPENAI_CHAT, "default"],
|
||||
[ApiFormat.R1_CHAT, "r1"],
|
||||
[ApiFormat.OPENAI_RESPONSES, "openai-responses"],
|
||||
] as const)("round-trips supported apiFormat %s through models.json", async (apiFormat, storedApiFormat) => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { apiFormat },
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
|
||||
apiFormat: storedApiFormat,
|
||||
})
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ apiFormat })
|
||||
})
|
||||
|
||||
it("normalizes invalid override values before storage and resolved legacy state", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
name: "Custom model",
|
||||
maxTokens: -1,
|
||||
contextWindow: Number.POSITIVE_INFINITY,
|
||||
maxInputTokens: 0,
|
||||
capabilities: ["tools", "tools", "vision", "unknown"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
inputPrice: Number.NaN,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: -1,
|
||||
temperature: -1,
|
||||
apiFormat: 999 as ApiFormat,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
|
||||
name: "Custom model",
|
||||
capabilities: ["tools"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0,
|
||||
})
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
expect(selection?.overrides).toEqual({
|
||||
name: "Custom model",
|
||||
capabilities: ["tools"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0,
|
||||
})
|
||||
expect(selection?.modelInfo.maxTokens).toBeUndefined()
|
||||
expect(selection?.modelInfo.temperature).toBe(0)
|
||||
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("maxTokens")
|
||||
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("temperature", -1)
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("deletes a stored entry when normalization removes every replacement field", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: { temperature: 0.2 },
|
||||
})
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
maxTokens: -1,
|
||||
contextWindow: 0,
|
||||
capabilities: ["vision", "unknown"],
|
||||
inputPrice: Number.NaN,
|
||||
temperature: -1,
|
||||
apiFormat: 999 as ApiFormat,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
|
||||
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
|
||||
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("normalizes invalid values already present in models.json on read", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setProviderSettings({
|
||||
"openai-compatible": { provider: "openai-compatible", model: "custom-model" },
|
||||
})
|
||||
mocks.setModelsFile({
|
||||
version: 1,
|
||||
providers: {
|
||||
"openai-compatible": {
|
||||
models: {
|
||||
"custom-model": {
|
||||
maxTokens: -1,
|
||||
contextWindow: 64_000,
|
||||
inputPrice: -2,
|
||||
temperature: -1,
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
const selection = store.readSelection(providerId, "act")
|
||||
|
||||
expect(selection?.overrides).toEqual({ contextWindow: 64_000, capabilities: ["tools"] })
|
||||
expect(selection?.modelInfo.maxTokens).toBeUndefined()
|
||||
expect(selection?.modelInfo.temperature).toBe(0)
|
||||
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("lets explicit capability booleans win and applies the R1 alias deterministically", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
capabilities: ["images", "prompt-cache", "reasoning"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: false,
|
||||
isR1FormatRequired: false,
|
||||
},
|
||||
})
|
||||
let selection = store.readSelection(providerId, "act")
|
||||
expect(selection?.modelInfo).toMatchObject({
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: false,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
})
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "custom-model",
|
||||
overrides: {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
capabilities: ["prompt-cache"],
|
||||
supportsVision: true,
|
||||
isR1FormatRequired: true,
|
||||
},
|
||||
})
|
||||
selection = store.readSelection(providerId, "act")
|
||||
expect(selection?.modelInfo).toMatchObject({
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
apiFormat: ApiFormat.R1_CHAT,
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
|
||||
@@ -259,14 +743,14 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
|
||||
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
|
||||
const planSelection = selectionFromModelInfo(providerId, "plan-openai-model", modelInfoA)
|
||||
const actSelection = selectionFromModelInfo(providerId, "act-openai-model", modelInfoB)
|
||||
|
||||
store.commitSelection(providerId, "plan", planSelection)
|
||||
store.commitSelection(providerId, "act", actSelection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
|
||||
expect(mocks.getApiConfiguration()).toMatchObject({
|
||||
planModeOpenAiModelId: "plan-openai-model",
|
||||
planModeOpenAiModelInfo: modelInfoA,
|
||||
@@ -286,12 +770,12 @@ describe("createProviderConfigStore", () => {
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "shared-openai-model", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(selection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "plan"), selection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
|
||||
expect(mocks.getApiConfiguration()).toMatchObject({
|
||||
planModeOpenAiModelId: "shared-openai-model",
|
||||
planModeOpenAiModelInfo: modelInfoA,
|
||||
@@ -320,14 +804,22 @@ describe("createProviderConfigStore", () => {
|
||||
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
|
||||
})
|
||||
|
||||
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
|
||||
it("resolves a bare state modelId with fallback metadata and ignores a bare modelInfo", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
|
||||
// The mode-specific model id alone identifies the selection; commits
|
||||
// whose resolution was pure fallback intentionally leave the state
|
||||
// modelInfo snapshot unset.
|
||||
mocks.setApiConfiguration({ actModeOpenRouterModelId: "anthropic/claude-sonnet-4" })
|
||||
expect(store.readSelection(providerId, "act")).toBeUndefined()
|
||||
expect(store.readSelection(providerId, "act")).toMatchObject({
|
||||
providerId,
|
||||
modelId: "anthropic/claude-sonnet-4",
|
||||
modelInfoSource: "fallback",
|
||||
})
|
||||
|
||||
// A modelInfo snapshot without a model id is not a selection.
|
||||
mocks.setApiConfiguration({ actModeOpenRouterModelInfo: modelInfoA })
|
||||
expect(store.readSelection(providerId, "act")).toBeUndefined()
|
||||
})
|
||||
@@ -337,14 +829,14 @@ describe("createProviderConfigStore", () => {
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
const actSelection = { providerId, modelId: "provider/model-b", modelInfo: modelInfoB }
|
||||
const planSelection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
|
||||
const actSelection = selectionFromModelInfo(providerId, "provider/model-b", modelInfoB)
|
||||
|
||||
store.commitSelection(providerId, "plan", planSelection)
|
||||
store.commitSelection(providerId, "act", actSelection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
|
||||
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
|
||||
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
|
||||
provider: "openrouter",
|
||||
model: "provider/model-b",
|
||||
@@ -361,7 +853,7 @@ describe("createProviderConfigStore", () => {
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
@@ -381,7 +873,7 @@ describe("createProviderConfigStore", () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("claude-code")
|
||||
const selection = { providerId, modelId: "haiku", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "haiku", modelInfoA)
|
||||
|
||||
store.commitSelection(providerId, "act", selection)
|
||||
|
||||
@@ -423,7 +915,7 @@ describe("createProviderConfigStore", () => {
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const events: ProviderConfigChange[] = []
|
||||
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
|
||||
|
||||
store.subscribe((event) => events.push(event))
|
||||
store.write(providerId, { apiKey: "openrouter-key" })
|
||||
@@ -431,7 +923,12 @@ describe("createProviderConfigStore", () => {
|
||||
|
||||
expect(events.map((event) => event.kind)).toEqual(["fields", "selection"])
|
||||
expect(events[0]).toMatchObject({ kind: "fields", providerId })
|
||||
expect(events[1]).toEqual({ kind: "selection", providerId, mode: "act", selection })
|
||||
expect(events[1]).toEqual({
|
||||
kind: "selection",
|
||||
providerId,
|
||||
mode: "act",
|
||||
selection: store.readSelection(providerId, "act"),
|
||||
})
|
||||
})
|
||||
|
||||
it("dispose unregisters listeners", async () => {
|
||||
@@ -446,4 +943,50 @@ describe("createProviderConfigStore", () => {
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Contract test against the REAL SDK schemas (imported by relative path,
|
||||
// bypassing the @cline/core mock above): the store's converters must pass
|
||||
// every SDK capability through, and a fully-populated stored entry must
|
||||
// parse under the schema `writeModelsFileSync` enforces in production.
|
||||
it("round-trips every SDK model capability and a full override set under the real stored-entry schema", async () => {
|
||||
const { ModelCapabilitySchema } = await import("@cline/shared")
|
||||
// vi.importActual bypasses the @cline/core mock above and resolves via
|
||||
// the vitest alias to the stub, which re-exports the real schema.
|
||||
const { StoredModelEntrySchema } = (await vi.importActual("@cline/core")) as {
|
||||
StoredModelEntrySchema: { parse(input: unknown): unknown }
|
||||
}
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openai")
|
||||
|
||||
store.commitSelection(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "contract-model",
|
||||
overrides: {
|
||||
name: "Contract Model",
|
||||
maxTokens: 1024,
|
||||
contextWindow: 200_000,
|
||||
maxInputTokens: 100_000,
|
||||
capabilities: [...ModelCapabilitySchema.options],
|
||||
supportsVision: true,
|
||||
supportsAttachments: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.5,
|
||||
outputPrice: 1.5,
|
||||
cacheReadsPrice: 0.1,
|
||||
cacheWritesPrice: 0.2,
|
||||
temperature: 0.7,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
isR1FormatRequired: true,
|
||||
},
|
||||
})
|
||||
|
||||
const entry = mocks.getModelsFile().providers["openai-compatible"]?.models?.["contract-model"]
|
||||
expect(entry).toBeDefined()
|
||||
// No SDK capability may be silently stripped by the store's converter.
|
||||
expect([...(entry?.capabilities as string[])].sort()).toEqual([...ModelCapabilitySchema.options].sort())
|
||||
// The entry written by the extension must satisfy the real schema that
|
||||
// the SDK's writeModelsFileSync enforces.
|
||||
expect(() => StoredModelEntrySchema.parse(entry)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import {
|
||||
readModelsFileSync,
|
||||
resolveModelsRegistryPath,
|
||||
type StoredModelEntry,
|
||||
syncStoredProviderRegistration,
|
||||
writeModelsFileSync,
|
||||
} from "@cline/core"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import { ModelCapabilitySchema } from "@cline/shared"
|
||||
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
@@ -9,14 +19,17 @@ import type {
|
||||
EffectiveProviderConfig,
|
||||
Mode,
|
||||
ModelSelection,
|
||||
ModelSelectionOverrides,
|
||||
ProviderConfigChange,
|
||||
ProviderConfigChangeListener,
|
||||
ProviderConfigPatch,
|
||||
ProviderConfigStore,
|
||||
ProviderId,
|
||||
ResolvedModelSelection,
|
||||
} from "./contracts"
|
||||
import { buildEffectiveProviderConfig } from "./effective-config"
|
||||
import { applyHostModelInfoOverrides } from "./host-overrides"
|
||||
import { fromSdkApiFormat, nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-values"
|
||||
import { toSdkProviderId } from "./sdk-provider-id"
|
||||
import { adaptSdkModelInfo } from "./shape-adapter"
|
||||
|
||||
@@ -113,7 +126,7 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
|
||||
// provider+mode so that switching between providers that share the same
|
||||
// `*ModeApiModelId` key does not combine one provider's model id with
|
||||
// another provider's model info.
|
||||
const selectionMemory = new Map<string, ModelSelection>()
|
||||
const selectionMemory = new Map<string, ResolvedModelSelection>()
|
||||
|
||||
function providerKey(providerId: ProviderId): string {
|
||||
return providerId.toString()
|
||||
@@ -168,11 +181,210 @@ function readProviderSettingsModelId(providerId: ProviderId): string | undefined
|
||||
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
|
||||
}
|
||||
|
||||
function fallbackModelInfo(modelId: string): ModelInfo {
|
||||
return { ...openAiModelInfoSafeDefaults, name: modelId }
|
||||
function sanitizeResolvedModelInfo(modelInfo: ModelInfo): ModelInfo {
|
||||
const next = { ...modelInfo }
|
||||
if (positiveFiniteNumber(next.maxTokens) === undefined) delete next.maxTokens
|
||||
if (nonNegativeFiniteNumber(next.temperature) === undefined) delete next.temperature
|
||||
return next
|
||||
}
|
||||
|
||||
function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
|
||||
function fallbackModelInfo(modelId: string): ModelInfo {
|
||||
return sanitizeResolvedModelInfo({ ...openAiModelInfoSafeDefaults, name: modelId })
|
||||
}
|
||||
|
||||
function toStoredCapabilities(capabilities: readonly string[] | undefined): StoredModelEntry["capabilities"] | undefined {
|
||||
if (!capabilities) {
|
||||
return undefined
|
||||
}
|
||||
// Validate against the SDK schema rather than a hardcoded list so new
|
||||
// capabilities added to ModelCapabilitySchema are never silently stripped.
|
||||
const next = new Set<NonNullable<StoredModelEntry["capabilities"]>[number]>()
|
||||
for (const capability of capabilities) {
|
||||
const parsed = ModelCapabilitySchema.safeParse(capability)
|
||||
if (parsed.success) {
|
||||
next.add(parsed.data)
|
||||
}
|
||||
}
|
||||
return next.size > 0 ? [...next] : undefined
|
||||
}
|
||||
|
||||
function toStoredApiFormat(apiFormat: ModelInfo["apiFormat"]): StoredModelEntry["apiFormat"] | undefined {
|
||||
return toSdkApiFormat(apiFormat)
|
||||
}
|
||||
|
||||
function fromStoredApiFormat(apiFormat: StoredModelEntry["apiFormat"]): ModelInfo["apiFormat"] | undefined {
|
||||
return fromSdkApiFormat(apiFormat)
|
||||
}
|
||||
|
||||
function readModelsState() {
|
||||
return readModelsFileSync(resolveModelsRegistryPath(getProviderSettingsManager()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes user-authored model metadata at the host/storage boundary.
|
||||
* Token limits must be positive, prices and temperatures non-negative, and
|
||||
* unsupported capabilities/formats are omitted. UI sentinels never cross
|
||||
* this boundary; an object with no meaningful fields becomes undefined.
|
||||
*/
|
||||
function normalizeModelSelectionOverrides(overrides: ModelSelectionOverrides | undefined): ModelSelectionOverrides | undefined {
|
||||
if (!overrides) {
|
||||
return undefined
|
||||
}
|
||||
const maxTokens = positiveFiniteNumber(overrides.maxTokens)
|
||||
const contextWindow = positiveFiniteNumber(overrides.contextWindow)
|
||||
const maxInputTokens = positiveFiniteNumber(overrides.maxInputTokens)
|
||||
const capabilities = toStoredCapabilities(overrides.capabilities)
|
||||
const inputPrice = nonNegativeFiniteNumber(overrides.inputPrice)
|
||||
const outputPrice = nonNegativeFiniteNumber(overrides.outputPrice)
|
||||
const cacheReadsPrice = nonNegativeFiniteNumber(overrides.cacheReadsPrice)
|
||||
const cacheWritesPrice = nonNegativeFiniteNumber(overrides.cacheWritesPrice)
|
||||
const temperature = nonNegativeFiniteNumber(overrides.temperature)
|
||||
const apiFormat = toStoredApiFormat(overrides.apiFormat) !== undefined ? overrides.apiFormat : undefined
|
||||
const next: ModelSelectionOverrides = {
|
||||
...(overrides.name !== undefined ? { name: overrides.name } : {}),
|
||||
...(maxTokens !== undefined ? { maxTokens } : {}),
|
||||
...(contextWindow !== undefined ? { contextWindow } : {}),
|
||||
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
|
||||
...(capabilities !== undefined ? { capabilities } : {}),
|
||||
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
|
||||
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
|
||||
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
|
||||
...(inputPrice !== undefined ? { inputPrice } : {}),
|
||||
...(outputPrice !== undefined ? { outputPrice } : {}),
|
||||
...(cacheReadsPrice !== undefined ? { cacheReadsPrice } : {}),
|
||||
...(cacheWritesPrice !== undefined ? { cacheWritesPrice } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined
|
||||
}
|
||||
|
||||
function toStoredModelEntry(overrides: ModelSelectionOverrides): StoredModelEntry {
|
||||
const capabilities = toStoredCapabilities(overrides.capabilities)
|
||||
const apiFormat = toStoredApiFormat(overrides.apiFormat)
|
||||
return {
|
||||
...(overrides.name !== undefined ? { name: overrides.name } : {}),
|
||||
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
|
||||
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
|
||||
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
|
||||
...(capabilities !== undefined ? { capabilities } : {}),
|
||||
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
|
||||
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
|
||||
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
|
||||
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
|
||||
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
|
||||
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
|
||||
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
|
||||
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function toSelectionOverrides(entry: StoredModelEntry | undefined): ModelSelectionOverrides | undefined {
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
const apiFormat = fromStoredApiFormat(entry.apiFormat)
|
||||
return normalizeModelSelectionOverrides({
|
||||
...(entry.name !== undefined ? { name: entry.name } : {}),
|
||||
...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}),
|
||||
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
|
||||
...(entry.maxInputTokens !== undefined ? { maxInputTokens: entry.maxInputTokens } : {}),
|
||||
...(entry.capabilities !== undefined ? { capabilities: [...entry.capabilities] } : {}),
|
||||
...(entry.supportsVision !== undefined ? { supportsVision: entry.supportsVision } : {}),
|
||||
...(entry.supportsAttachments !== undefined ? { supportsAttachments: entry.supportsAttachments } : {}),
|
||||
...(entry.supportsReasoning !== undefined ? { supportsReasoning: entry.supportsReasoning } : {}),
|
||||
...(entry.inputPrice !== undefined ? { inputPrice: entry.inputPrice } : {}),
|
||||
...(entry.outputPrice !== undefined ? { outputPrice: entry.outputPrice } : {}),
|
||||
...(entry.cacheReadsPrice !== undefined ? { cacheReadsPrice: entry.cacheReadsPrice } : {}),
|
||||
...(entry.cacheWritesPrice !== undefined ? { cacheWritesPrice: entry.cacheWritesPrice } : {}),
|
||||
...(entry.temperature !== undefined ? { temperature: entry.temperature } : {}),
|
||||
...(apiFormat !== undefined ? { apiFormat } : {}),
|
||||
...(entry.isR1FormatRequired !== undefined ? { isR1FormatRequired: entry.isR1FormatRequired } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function readStoredModelEntry(providerId: ProviderId, modelId: string): { exists: boolean; entry: StoredModelEntry | undefined } {
|
||||
const models = readModelsState().providers[providerSettingsProviderId(providerId)]?.models
|
||||
return {
|
||||
exists: models ? Object.hasOwn(models, modelId) : false,
|
||||
entry: models?.[modelId],
|
||||
}
|
||||
}
|
||||
|
||||
function readModelOverrides(providerId: ProviderId, modelId: string): ModelSelectionOverrides | undefined {
|
||||
return toSelectionOverrides(readStoredModelEntry(providerId, modelId).entry)
|
||||
}
|
||||
|
||||
function writeModelOverrides(providerId: ProviderId, modelId: string, overrides: ModelSelectionOverrides | undefined): void {
|
||||
const modelsPath = resolveModelsRegistryPath(getProviderSettingsManager())
|
||||
const state = readModelsFileSync(modelsPath)
|
||||
const provider = providerSettingsProviderId(providerId)
|
||||
const providerEntry = state.providers[provider] ?? {}
|
||||
const nextModels = { ...(providerEntry.models ?? {}) }
|
||||
const normalizedOverrides = normalizeModelSelectionOverrides(overrides)
|
||||
const storedEntry = normalizedOverrides ? toStoredModelEntry(normalizedOverrides) : undefined
|
||||
if (storedEntry && Object.keys(storedEntry).length > 0) {
|
||||
nextModels[modelId] = storedEntry
|
||||
} else {
|
||||
delete nextModels[modelId]
|
||||
}
|
||||
const nextProviderEntry = {
|
||||
...providerEntry,
|
||||
models: nextModels,
|
||||
}
|
||||
writeModelsFileSync(modelsPath, {
|
||||
...state,
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: nextProviderEntry,
|
||||
},
|
||||
})
|
||||
// ensureCustomProvidersLoadedSync is load-once per path and would no-op
|
||||
// here; sync the live registry explicitly so this write is visible to new
|
||||
// sessions without a restart.
|
||||
syncStoredProviderRegistration(provider, state.providers[provider], nextProviderEntry)
|
||||
}
|
||||
|
||||
function applyModelOverrides(modelInfo: ModelInfo, overrides: ModelSelectionOverrides | undefined): ModelInfo {
|
||||
if (!overrides) {
|
||||
return modelInfo
|
||||
}
|
||||
const next: ModelInfo = { ...modelInfo }
|
||||
if (overrides.name !== undefined) next.name = overrides.name
|
||||
if (overrides.maxTokens !== undefined) next.maxTokens = overrides.maxTokens
|
||||
if (overrides.contextWindow !== undefined) next.contextWindow = overrides.contextWindow
|
||||
if (overrides.maxInputTokens !== undefined)
|
||||
(next as ModelInfo & { maxInputTokens?: number }).maxInputTokens = overrides.maxInputTokens
|
||||
if (overrides.inputPrice !== undefined) next.inputPrice = overrides.inputPrice
|
||||
if (overrides.outputPrice !== undefined) next.outputPrice = overrides.outputPrice
|
||||
if (overrides.cacheReadsPrice !== undefined) next.cacheReadsPrice = overrides.cacheReadsPrice
|
||||
if (overrides.cacheWritesPrice !== undefined) next.cacheWritesPrice = overrides.cacheWritesPrice
|
||||
if (overrides.temperature !== undefined) next.temperature = overrides.temperature
|
||||
if (overrides.apiFormat !== undefined) next.apiFormat = overrides.apiFormat
|
||||
|
||||
// Capability arrays are additive fallback flags: they can only enable
|
||||
// capabilities the base metadata lacks, never disable base capabilities
|
||||
// (an array authored for one purpose, e.g. prompt-cache, must not strip
|
||||
// unrelated base flags like vision). Explicit booleans win when both
|
||||
// representations are present.
|
||||
if (overrides.capabilities !== undefined) {
|
||||
if (overrides.capabilities.includes("images")) next.supportsImages = true
|
||||
if (overrides.capabilities.includes("prompt-cache")) next.supportsPromptCache = true
|
||||
if (overrides.capabilities.includes("reasoning")) next.supportsReasoning = true
|
||||
}
|
||||
if (overrides.supportsVision !== undefined) next.supportsImages = overrides.supportsVision
|
||||
if (overrides.supportsReasoning !== undefined) next.supportsReasoning = overrides.supportsReasoning
|
||||
|
||||
// apiFormat is canonical. The legacy R1 flag remains a compatibility alias
|
||||
// that forces R1 only when explicitly true.
|
||||
if (overrides.isR1FormatRequired) next.apiFormat = ApiFormat.R1_CHAT
|
||||
return next
|
||||
}
|
||||
|
||||
function readBaseModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const generatedModelInfo = getGeneratedModelsForProvider(sdkProviderId)[modelId]
|
||||
if (isModelInfo(generatedModelInfo)) {
|
||||
@@ -201,17 +413,36 @@ function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string):
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelection | undefined {
|
||||
function resolveSelection(selection: ModelSelection, stateModelInfoHint?: ModelInfo): ResolvedModelSelection {
|
||||
const overrides = normalizeModelSelectionOverrides(
|
||||
selection.overrides ?? readModelOverrides(selection.providerId, selection.modelId),
|
||||
)
|
||||
// Base resolution order: SDK catalog, then the picker's persisted state
|
||||
// snapshot (the only accurate data for dynamic-list models the static
|
||||
// catalog does not know), then provider-safe fallback defaults.
|
||||
const catalogModelInfo = readBaseModelInfoForProvider(selection.providerId, selection.modelId)
|
||||
const baseModelInfo = catalogModelInfo ?? stateModelInfoHint ?? fallbackModelInfo(selection.modelId)
|
||||
const modelInfoSource = catalogModelInfo ? "catalog" : stateModelInfoHint ? "state" : "fallback"
|
||||
return {
|
||||
...selection,
|
||||
overrides,
|
||||
modelInfoSource,
|
||||
baseModelInfo,
|
||||
modelInfo: sanitizeResolvedModelInfo(applyModelOverrides(baseModelInfo, overrides)),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRuntimeModelSelection(providerId: ProviderId, modelId: string): ResolvedModelSelection {
|
||||
return resolveSelection({ providerId, modelId })
|
||||
}
|
||||
|
||||
function readSelectionFromProviderSettings(providerId: ProviderId): ResolvedModelSelection | undefined {
|
||||
const modelId = readProviderSettingsModelId(providerId)
|
||||
if (!modelId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
|
||||
}
|
||||
return resolveSelection({ providerId, modelId })
|
||||
}
|
||||
|
||||
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
|
||||
@@ -281,6 +512,17 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
|
||||
writeStateKey("clineApiKey", patch.auth?.accessToken)
|
||||
writeStateKey("clineAccountId", patch.auth?.accountId)
|
||||
}
|
||||
|
||||
// Mirror the Ollama context window to the legacy state key so older
|
||||
// readers (proto ApiConfiguration, webview display fallback) stay in sync
|
||||
// with providers.json.
|
||||
if (provider === "ollama" && "contextWindow" in patch) {
|
||||
const contextWindow = patch.contextWindow
|
||||
writeStateKey(
|
||||
"ollamaApiOptionsCtxNum",
|
||||
typeof contextWindow === "number" && contextWindow > 0 ? String(contextWindow) : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
|
||||
@@ -330,6 +572,15 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
|
||||
}
|
||||
}
|
||||
|
||||
if ("contextWindow" in patch) {
|
||||
const contextWindow = patch.contextWindow
|
||||
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
|
||||
next.contextWindow = Math.floor(contextWindow)
|
||||
} else {
|
||||
delete next.contextWindow
|
||||
}
|
||||
}
|
||||
|
||||
if ("aws" in patch) {
|
||||
const awsPatch = patch.aws
|
||||
if (awsPatch === null || awsPatch === undefined) {
|
||||
@@ -389,13 +640,27 @@ function syncedModes(mode: Mode): Mode[] {
|
||||
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
|
||||
}
|
||||
|
||||
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
|
||||
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ResolvedModelSelection): void {
|
||||
const updates: Partial<Record<SettingsKey, unknown>> = {}
|
||||
for (const targetMode of syncedModes(mode)) {
|
||||
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
|
||||
const modelInfoKey = getModelInfoKey(providerId, targetMode)
|
||||
if (modelInfoKey) {
|
||||
updates[modelInfoKey] = selection.modelInfo
|
||||
// For hint-eligible providers the snapshot must stay genuine base
|
||||
// metadata: never persist fabricated fallback data (later reads
|
||||
// would treat it as authoritative "state" data and shadow live
|
||||
// catalog lookups), and persist the pre-override base rather than
|
||||
// the resolved value (a deleted override must not be resurrected
|
||||
// from a snapshot it was baked into). openai-compatible keeps the
|
||||
// legacy resolved write — its snapshot is never used as a
|
||||
// resolution base, and old extension versions still read it after
|
||||
// a rollback.
|
||||
if (usesStateModelInfoHint(providerId)) {
|
||||
updates[modelInfoKey] =
|
||||
selection.modelInfoSource === "fallback" ? undefined : (selection.baseModelInfo ?? selection.modelInfo)
|
||||
} else {
|
||||
updates[modelInfoKey] = selection.modelInfo
|
||||
}
|
||||
}
|
||||
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
|
||||
}
|
||||
@@ -404,28 +669,164 @@ function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: Mo
|
||||
|
||||
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
|
||||
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
|
||||
// Prune model metadata that earlier builds may have written to providers.json.
|
||||
delete next.contextWindow
|
||||
// Prune model metadata that earlier builds may have written to
|
||||
// providers.json — except for Ollama, whose contextWindow is a real
|
||||
// user setting (maps to num_ctx) written by the settings UI.
|
||||
if (providerKey(providerId) !== "ollama") {
|
||||
delete next.contextWindow
|
||||
}
|
||||
delete next.maxTokens
|
||||
|
||||
saveProviderSettings(providerId, next)
|
||||
}
|
||||
|
||||
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
|
||||
type LegacyModelInfo = ModelInfo & { maxInputTokens?: number; isR1FormatRequired?: boolean }
|
||||
type MutableModelSelectionOverrides = { -readonly [Key in keyof ModelSelectionOverrides]: ModelSelectionOverrides[Key] }
|
||||
|
||||
function legacyModelInfoToOverrides(modelInfo: LegacyModelInfo, fallback: ModelInfo): ModelSelectionOverrides | undefined {
|
||||
const fallbackInfo = fallback as LegacyModelInfo
|
||||
const overrides: MutableModelSelectionOverrides = {}
|
||||
if (modelInfo.name !== undefined && modelInfo.name !== fallback.name) overrides.name = modelInfo.name
|
||||
if (modelInfo.maxTokens !== undefined && modelInfo.maxTokens !== fallback.maxTokens) overrides.maxTokens = modelInfo.maxTokens
|
||||
if (modelInfo.contextWindow !== undefined && modelInfo.contextWindow !== fallback.contextWindow)
|
||||
overrides.contextWindow = modelInfo.contextWindow
|
||||
if (modelInfo.maxInputTokens !== undefined && modelInfo.maxInputTokens !== fallbackInfo.maxInputTokens)
|
||||
overrides.maxInputTokens = modelInfo.maxInputTokens
|
||||
|
||||
const supportsVision = modelInfo.supportsImages ?? fallback.supportsImages
|
||||
if (Boolean(supportsVision) !== Boolean(fallback.supportsImages)) overrides.supportsVision = Boolean(supportsVision)
|
||||
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
|
||||
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
|
||||
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
|
||||
const capabilities: string[] = []
|
||||
if (supportsVision) capabilities.push("images")
|
||||
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
|
||||
overrides.capabilities = capabilities
|
||||
}
|
||||
|
||||
if (modelInfo.inputPrice !== undefined && modelInfo.inputPrice !== fallback.inputPrice)
|
||||
overrides.inputPrice = modelInfo.inputPrice
|
||||
if (modelInfo.outputPrice !== undefined && modelInfo.outputPrice !== fallback.outputPrice)
|
||||
overrides.outputPrice = modelInfo.outputPrice
|
||||
if (modelInfo.cacheReadsPrice !== undefined && modelInfo.cacheReadsPrice !== fallback.cacheReadsPrice)
|
||||
overrides.cacheReadsPrice = modelInfo.cacheReadsPrice
|
||||
if (modelInfo.cacheWritesPrice !== undefined && modelInfo.cacheWritesPrice !== fallback.cacheWritesPrice)
|
||||
overrides.cacheWritesPrice = modelInfo.cacheWritesPrice
|
||||
if (modelInfo.temperature !== undefined && modelInfo.temperature !== fallback.temperature)
|
||||
overrides.temperature = modelInfo.temperature
|
||||
if (modelInfo.apiFormat !== undefined && modelInfo.apiFormat !== fallback.apiFormat) overrides.apiFormat = modelInfo.apiFormat
|
||||
if (modelInfo.isR1FormatRequired === true && fallbackInfo.isR1FormatRequired !== true) overrides.isR1FormatRequired = true
|
||||
return normalizeModelSelectionOverrides(overrides)
|
||||
}
|
||||
|
||||
// Providers/models whose legacy-state migration has already been attempted in
|
||||
// this process. The migration runs from the read path, so it must be cheap on
|
||||
// repeat reads and must never run more than once per selection — including
|
||||
// when the legacy snapshot diffs to an empty override set and nothing is
|
||||
// written.
|
||||
const attemptedLegacyMigrations = new Set<string>()
|
||||
|
||||
function migrateLegacyModelOverridesIfNeeded(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): void {
|
||||
if (providerSettingsProviderId(providerId) !== "openai-compatible") {
|
||||
return
|
||||
}
|
||||
const migrationKey = `${providerId}:${modelId}`
|
||||
if (attemptedLegacyMigrations.has(migrationKey)) {
|
||||
return
|
||||
}
|
||||
attemptedLegacyMigrations.add(migrationKey)
|
||||
if (readStoredModelEntry(providerId, modelId).exists) {
|
||||
return
|
||||
}
|
||||
if (readBaseModelInfoForProvider(providerId, modelId) !== undefined) {
|
||||
return
|
||||
}
|
||||
const overrides = legacyModelInfoToOverrides(modelInfo as LegacyModelInfo, fallbackModelInfo(modelId))
|
||||
if (overrides) {
|
||||
try {
|
||||
writeModelOverrides(providerId, modelId, overrides)
|
||||
} catch (error) {
|
||||
// The migration is best-effort and runs inside read paths; a
|
||||
// failed write (read-only fs, disk full) must not fail read RPCs.
|
||||
Logger.warn(
|
||||
`[ModelCatalog] Failed to migrate legacy overrides for provider=${providerId} model=${modelId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker writes the live model metadata to the mode-specific
|
||||
* `*ModeModelInfo` state key before committing. When the state still refers to
|
||||
* the model being resolved, that snapshot is the best available base for
|
||||
* dynamic-list models the static catalog does not know.
|
||||
*
|
||||
* openai-compatible is excluded: its legacy state snapshot is user-authored
|
||||
* metadata that {@link migrateLegacyModelOverridesIfNeeded} converts into
|
||||
* models.json overrides, which are the source of truth there. Feeding the
|
||||
* snapshot back as a base would resurrect overrides the user deleted.
|
||||
*/
|
||||
function usesStateModelInfoHint(providerId: ProviderId): boolean {
|
||||
return providerSettingsProviderId(providerId) !== "openai-compatible"
|
||||
}
|
||||
|
||||
/**
|
||||
* Pickers write `{ ...openAiModelInfoSafeDefaults, name: modelId }` to the
|
||||
* state key when the user selects an id the live model list does not (yet)
|
||||
* contain. Such a snapshot carries no real information and must not be
|
||||
* treated as authoritative "state" metadata.
|
||||
*/
|
||||
function isSafeDefaultsSnapshot(modelInfo: ModelInfo, modelId: string): boolean {
|
||||
const fabricated: Record<string, unknown> = { ...openAiModelInfoSafeDefaults, name: modelId }
|
||||
const snapshot = modelInfo as unknown as Record<string, unknown>
|
||||
for (const key of new Set([...Object.keys(fabricated), ...Object.keys(snapshot)])) {
|
||||
if (fabricated[key] !== snapshot[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function readStateModelInfoHint(providerId: ProviderId, mode: Mode, modelId: string): ModelInfo | undefined {
|
||||
if (!usesStateModelInfoHint(providerId)) {
|
||||
return undefined
|
||||
}
|
||||
const modelInfoKey = getModelInfoKey(providerId, mode)
|
||||
if (!modelInfoKey) {
|
||||
return undefined
|
||||
}
|
||||
const apiConfiguration = StateManager.get().getApiConfiguration()
|
||||
const stateModelId = apiConfiguration[getModelIdKey(providerId, mode)]
|
||||
const stateModelInfo = apiConfiguration[modelInfoKey]
|
||||
return stateModelId === modelId && isModelInfo(stateModelInfo) && !isSafeDefaultsSnapshot(stateModelInfo, modelId)
|
||||
? stateModelInfo
|
||||
: undefined
|
||||
}
|
||||
|
||||
function readSelectionFromState(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
|
||||
const apiConfiguration = StateManager.get().getApiConfiguration()
|
||||
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
|
||||
const modelInfoKey = getModelInfoKey(providerId, mode)
|
||||
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
|
||||
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
|
||||
|
||||
if (modelInfoKey) {
|
||||
const modelInfo = apiConfiguration[modelInfoKey]
|
||||
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
|
||||
return providerSettingsSelection
|
||||
if (typeof modelId !== "string" || modelId.length === 0) {
|
||||
return readSelectionFromProviderSettings(providerId)
|
||||
}
|
||||
return { providerId, modelId, modelInfo }
|
||||
// The mode-specific model id alone identifies the selection; the state
|
||||
// modelInfo snapshot is optional input for legacy migration and, for
|
||||
// dynamic-list providers, the base-metadata hint. Fallback-tier commits
|
||||
// intentionally leave it unset.
|
||||
if (isModelInfo(modelInfo)) {
|
||||
migrateLegacyModelOverridesIfNeeded(providerId, modelId, modelInfo)
|
||||
}
|
||||
return resolveSelection({ providerId, modelId }, readStateModelInfoHint(providerId, mode, modelId))
|
||||
}
|
||||
|
||||
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
|
||||
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
|
||||
const provider = providerForStorage(providerId)
|
||||
if (activeProvider !== provider) {
|
||||
@@ -464,7 +865,7 @@ export function createProviderConfigStore(): ProviderConfigStore {
|
||||
return { ...buildEffectiveProviderConfig(providerId) }
|
||||
},
|
||||
|
||||
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
|
||||
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
|
||||
return readSelectionFromState(providerId, mode)
|
||||
},
|
||||
|
||||
@@ -482,9 +883,17 @@ export function createProviderConfigStore(): ProviderConfigStore {
|
||||
},
|
||||
|
||||
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
|
||||
writeSelectionToState(providerId, mode, selection)
|
||||
writeSelectionToProviderSettings(providerId, selection)
|
||||
emit({ kind: "selection", providerId, mode, selection })
|
||||
if (selection.overrides !== undefined) {
|
||||
writeModelOverrides(providerId, selection.modelId, selection.overrides)
|
||||
}
|
||||
// Read the picker-written state snapshot before writeSelectionToState
|
||||
// replaces it, so dynamic-list models keep their live metadata instead
|
||||
// of being re-resolved to fallback defaults.
|
||||
const stateModelInfoHint = readStateModelInfoHint(providerId, mode, selection.modelId)
|
||||
const resolvedSelection = resolveSelection({ providerId, modelId: selection.modelId }, stateModelInfoHint)
|
||||
writeSelectionToState(providerId, mode, resolvedSelection)
|
||||
emit({ kind: "selection", providerId, mode, selection: resolvedSelection })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readModelsFileSync, writeModelsFileSync } from "@cline/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
type StoredModelsFile = ReturnType<typeof readModelsFileSync>
|
||||
|
||||
const firstPath = "/tmp/first-models.json"
|
||||
const secondPath = "/tmp/second-models.json"
|
||||
|
||||
const storedModelsFile = (): StoredModelsFile => ({
|
||||
version: 1,
|
||||
providers: {
|
||||
"openai-compatible": {
|
||||
models: {
|
||||
custom: { name: "Custom", capabilities: ["tools"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe("cline core model-file test stub", () => {
|
||||
it("isolates paths and returns defensive copies", () => {
|
||||
const input = storedModelsFile()
|
||||
writeModelsFileSync(firstPath, input)
|
||||
|
||||
input.providers["openai-compatible"].models!.custom.name = "mutated input"
|
||||
const firstRead = readModelsFileSync(firstPath)
|
||||
firstRead.providers["openai-compatible"].models!.custom.name = "mutated read"
|
||||
|
||||
expect(readModelsFileSync(firstPath)).toEqual(storedModelsFile())
|
||||
expect(readModelsFileSync(secondPath)).toEqual({ version: 1, providers: {} })
|
||||
})
|
||||
|
||||
it("cannot observe model writes from the preceding test", () => {
|
||||
expect(readModelsFileSync(firstPath)).toEqual({ version: 1, providers: {} })
|
||||
})
|
||||
})
|
||||
@@ -454,13 +454,35 @@ describe("SdkDiffEditCoordinator", () => {
|
||||
expect(callOrder).toEqual(["close", "apply"])
|
||||
})
|
||||
|
||||
it("applies patches without preview sessions directly", async () => {
|
||||
it("shows a brief preview around auto-approved patches", async () => {
|
||||
await writeFile("patched.ts", "line one\nline two\n")
|
||||
const patch = ["*** Begin Patch", "*** Update File: patched.ts", "@@", "-line one", "+line ONE", "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
|
||||
const result = await coordinator.executeApplyPatchTool({ input: patch }, tempDir, makeContext("tc9"))
|
||||
|
||||
expect(result).toBe("fallback apply_patch result")
|
||||
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
|
||||
expect(previews).toHaveLength(1)
|
||||
expect(previews[0].opened).toMatchObject({
|
||||
absolutePath: path.join(tempDir, "patched.ts"),
|
||||
leftContent: "line one\nline two\n",
|
||||
rightContent: "line ONE\nline two\n",
|
||||
})
|
||||
expect(previews[0].closed).toBe(1)
|
||||
})
|
||||
|
||||
it("applies auto-approved patches without a preview when background edit is enabled", async () => {
|
||||
backgroundEdit = true
|
||||
const result = await coordinator.executeApplyPatchTool(
|
||||
{ input: "*** Begin Patch\n*** End Patch" },
|
||||
tempDir,
|
||||
makeContext("tc9"),
|
||||
)
|
||||
|
||||
expect(result).toBe("fallback apply_patch result")
|
||||
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
|
||||
expect(previews).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,12 +122,32 @@ export class SdkDiffEditCoordinator {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `apply_patch` tool executor override: close the preview, then delegate the
|
||||
* whole patch application to the SDK's default executor.
|
||||
* The `apply_patch` tool executor override: manually-approved patches close their
|
||||
* approval preview before applying; auto-approved patches show a brief preview
|
||||
* around execution, matching the `editor` tool behavior.
|
||||
*/
|
||||
async executeApplyPatchTool(input: ApplyPatchInput, cwd: string, context: AgentToolContext): Promise<string> {
|
||||
await this.discardPreview(context.toolCallId ?? "")
|
||||
return this.fallbackApplyPatchExecutor(input, cwd, context)
|
||||
const toolCallId = context.toolCallId ?? ""
|
||||
const hadPreApprovalPreview = this.sessions.has(toolCallId)
|
||||
try {
|
||||
if (hadPreApprovalPreview) {
|
||||
await this.discardPreview(toolCallId)
|
||||
} else if (!this.options.isBackgroundEditEnabled()) {
|
||||
try {
|
||||
await this.openPatchPreview(toolCallId, input)
|
||||
} catch (error) {
|
||||
Logger.warn(`[SdkDiffEditCoordinator] Failed to show auto-approve patch preview: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.fallbackApplyPatchExecutor(input, cwd, context)
|
||||
if (!hadPreApprovalPreview && this.sessions.get(toolCallId)?.preview) {
|
||||
await lingerDelay(this.autoApprovePreviewLingerMs, context.signal)
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
await this.discardPreview(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes one preview (reject / abort / edit applied). Never throws; unknown ids are a no-op. */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
|
||||
describe("SdkForegroundCommandCoordinator", () => {
|
||||
it("reports isRunning while a handle is registered and notifies on changes", () => {
|
||||
const onRunningChanged = vi.fn()
|
||||
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
|
||||
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
|
||||
const unregister = coordinator.register({ detach: () => {} })
|
||||
expect(coordinator.isRunning).toBe(true)
|
||||
expect(onRunningChanged).toHaveBeenCalledWith(true)
|
||||
|
||||
unregister()
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
expect(onRunningChanged).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it("only notifies on actual transitions, not per handle", () => {
|
||||
const onRunningChanged = vi.fn()
|
||||
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
|
||||
|
||||
const unregister1 = coordinator.register({ detach: () => {} })
|
||||
const unregister2 = coordinator.register({ detach: () => {} })
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
unregister1()
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(1)
|
||||
unregister2()
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("unregister is idempotent", () => {
|
||||
const onRunningChanged = vi.fn()
|
||||
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
|
||||
|
||||
const unregister = coordinator.register({ detach: () => {} })
|
||||
unregister()
|
||||
unregister()
|
||||
expect(onRunningChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("proceedWhileRunning detaches every registered handle and reports the count", () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const detach1 = vi.fn()
|
||||
const detach2 = vi.fn()
|
||||
coordinator.register({ detach: detach1 })
|
||||
coordinator.register({ detach: detach2 })
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(2)
|
||||
expect(detach1).toHaveBeenCalledTimes(1)
|
||||
expect(detach2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("proceedWhileRunning is a no-op returning 0 when nothing is running", () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
expect(coordinator.proceedWhileRunning()).toBe(0)
|
||||
})
|
||||
|
||||
it("proceedWhileRunning survives a handle whose detach throws", () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const detach2 = vi.fn()
|
||||
coordinator.register({
|
||||
detach: () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
})
|
||||
coordinator.register({ detach: detach2 })
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(2)
|
||||
expect(detach2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tracks in-flight foreground (VS Code terminal) command executions so the
|
||||
* "Proceed While Running" button can detach them: each pending tool call
|
||||
* returns with its partial output while the command keeps running in the
|
||||
* user's terminal, streaming further output to a log file.
|
||||
*
|
||||
* Owned by SdkController so it outlives session rebuilds (which recreate the
|
||||
* tool set and its reused executor closure). Handles are registered per tool
|
||||
* invocation — never on the reused executor — so parallel commands in one
|
||||
* tool call each get their own handle and log file.
|
||||
*/
|
||||
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export interface ForegroundCommandHandle {
|
||||
/**
|
||||
* Stop waiting for the command: flush the output captured so far to a
|
||||
* log file, keep appending until the command completes, and resolve the
|
||||
* pending tool execution with the partial output. Idempotent.
|
||||
*/
|
||||
detach(): void
|
||||
}
|
||||
|
||||
export interface SdkForegroundCommandCoordinatorOptions {
|
||||
/** Called whenever isRunning flips; used to push the flag to the webview. */
|
||||
onRunningChanged?: (running: boolean) => void
|
||||
}
|
||||
|
||||
export class SdkForegroundCommandCoordinator {
|
||||
private readonly handles = new Set<ForegroundCommandHandle>()
|
||||
|
||||
constructor(private readonly options: SdkForegroundCommandCoordinatorOptions = {}) {}
|
||||
|
||||
/** Whether any foreground command is currently awaited by a tool call. */
|
||||
get isRunning(): boolean {
|
||||
return this.handles.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Track one in-flight foreground execution. Returns an unregister
|
||||
* function the caller must invoke when the execution settles (completes,
|
||||
* fails, aborts, or detaches) — typically from a `finally` block.
|
||||
*/
|
||||
register(handle: ForegroundCommandHandle): () => void {
|
||||
const wasRunning = this.isRunning
|
||||
this.handles.add(handle)
|
||||
this.notifyIfChanged(wasRunning)
|
||||
return () => {
|
||||
const wasRunningBefore = this.isRunning
|
||||
if (this.handles.delete(handle)) {
|
||||
this.notifyIfChanged(wasRunningBefore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach every in-flight foreground command ("Proceed While Running").
|
||||
* Each pending tool execution resolves with its partial output and log
|
||||
* file path; the commands keep running in their terminals.
|
||||
*
|
||||
* @returns the number of commands detached (0 when none were running).
|
||||
*/
|
||||
proceedWhileRunning(): number {
|
||||
const handles = [...this.handles]
|
||||
for (const handle of handles) {
|
||||
try {
|
||||
handle.detach()
|
||||
} catch (error) {
|
||||
Logger.error("[ForegroundCommands] Failed to detach foreground command:", error)
|
||||
}
|
||||
}
|
||||
return handles.length
|
||||
}
|
||||
|
||||
private notifyIfChanged(wasRunning: boolean): void {
|
||||
if (this.isRunning !== wasRunning) {
|
||||
this.options.onRunningChanged?.(this.isRunning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ActiveSession } from "./cline-session-factory"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { buildToolPolicies } from "./sdk-tool-policies"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
import { VscodeSessionHost } from "./vscode-session-host"
|
||||
@@ -32,6 +33,8 @@ export interface SdkSessionLifecycleOptions {
|
||||
onSessionEvent: (event: CoreSessionEvent) => void
|
||||
/** Lazy factory for the VscodeTerminalManager (foreground terminal support). */
|
||||
getTerminalManager?: () => VscodeTerminalManager
|
||||
/** Registry of in-flight foreground executions for "Proceed While Running". */
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
/** Returns the latest prepared remote-config integration, if remote config is active. */
|
||||
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
|
||||
/** Shared SDK telemetry service owned by SdkController. */
|
||||
@@ -322,6 +325,7 @@ export class SdkSessionLifecycle {
|
||||
editorExecutor: this.options.editorExecutor,
|
||||
applyPatchExecutor: this.options.applyPatchExecutor,
|
||||
getTerminalManager: this.options.getTerminalManager,
|
||||
foregroundCommands: this.options.foregroundCommands,
|
||||
getRemoteConfigIntegration: this.options.getRemoteConfigIntegration,
|
||||
telemetry: this.options.telemetry,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { CommandExitError } from "@cline/core"
|
||||
import { EventEmitter } from "events"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
|
||||
import * as fs from "fs"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import { executeForeground, formatCommandForTerminal } from "./vscode-run-commands-tool"
|
||||
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { executeForeground, formatCommandForTerminal, PROCEED_LOG_MAX_BYTES } from "./vscode-run-commands-tool"
|
||||
|
||||
// The real telemetry proxy lazily initializes TelemetryService, which requires
|
||||
// a HostProvider that unit tests don't set up.
|
||||
vi.mock("@services/telemetry", () => ({
|
||||
TerminalUserInterventionAction: { PROCESS_WHILE_RUNNING: "process_while_running" },
|
||||
telemetryService: {
|
||||
captureTerminalUserIntervention: () => {},
|
||||
captureTerminalExecution: () => {},
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* Minimal fake of the process object returned by VscodeTerminalManager.runCommand():
|
||||
@@ -12,11 +24,13 @@ import { executeForeground, formatCommandForTerminal } from "./vscode-run-comman
|
||||
*/
|
||||
function createFakeTerminalProcess(options: { lines?: string[]; completionDetails?: TerminalCompletionDetails } = {}) {
|
||||
const emitter = new EventEmitter()
|
||||
let resolvePromise!: () => void
|
||||
// Emit on a macrotask (not a microtask) so executeForeground's
|
||||
// `await terminalManager.getOrCreateTerminal(cwd)` and subsequent
|
||||
// `process.on("line", ...)` registration are guaranteed to run first,
|
||||
// matching the ordering a real terminal process provides.
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
setTimeout(() => {
|
||||
for (const line of options.lines ?? []) {
|
||||
emitter.emit("line", line)
|
||||
@@ -31,6 +45,10 @@ function createFakeTerminalProcess(options: { lines?: string[]; completionDetail
|
||||
catch: promise.catch.bind(promise),
|
||||
finally: promise.finally.bind(promise),
|
||||
getCompletionDetails: () => options.completionDetails ?? {},
|
||||
detach: () => {
|
||||
emitter.emit("continue")
|
||||
resolvePromise()
|
||||
},
|
||||
})
|
||||
return fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>
|
||||
}
|
||||
@@ -42,6 +60,50 @@ function createFakeTerminalManager(process: ReturnType<VscodeTerminalManager["ru
|
||||
} as unknown as VscodeTerminalManager
|
||||
}
|
||||
|
||||
/**
|
||||
* A controllable fake terminal process for detach tests: the caller decides
|
||||
* when lines are emitted and when the command completes. Mirrors the real
|
||||
* VscodeTerminalProcess contract: detach() resolves the awaited promise while
|
||||
* 'line'/'completed' events keep flowing.
|
||||
*/
|
||||
function createControllableTerminalProcess() {
|
||||
const emitter = new EventEmitter()
|
||||
let resolvePromise!: () => void
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
const fakeProcess = Object.assign(emitter, {
|
||||
then: promise.then.bind(promise),
|
||||
catch: promise.catch.bind(promise),
|
||||
finally: promise.finally.bind(promise),
|
||||
getCompletionDetails: () => ({}),
|
||||
detach: () => {
|
||||
emitter.emit("continue")
|
||||
resolvePromise()
|
||||
},
|
||||
})
|
||||
return {
|
||||
process: fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>,
|
||||
emitLine: (line: string) => emitter.emit("line", line),
|
||||
complete: (details?: TerminalCompletionDetails) => {
|
||||
emitter.emit("completed", details)
|
||||
emitter.emit("continue")
|
||||
resolvePromise()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll until the predicate holds, for asserting on async log-file writes. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("waitFor timed out")
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatCommandForTerminal", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -172,4 +234,207 @@ describe("executeForeground", () => {
|
||||
expect((error as InstanceType<typeof CommandExitError>).output).toContain("Terminal closed")
|
||||
}
|
||||
})
|
||||
|
||||
it("unregisters its foreground handle when the command completes normally", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const terminalManager = createFakeTerminalManager(createFakeTerminalProcess({ lines: ["hello"] }))
|
||||
|
||||
const result = await executeForeground("echo hello", "/workspace", terminalManager, 1000, undefined, coordinator)
|
||||
|
||||
expect(result).toBe("hello")
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeForeground — Proceed While Running", () => {
|
||||
it("detach returns the partial output with the log file path, and later output lands in the log", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
emitLine("listening on :3000")
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result).toContain("still running")
|
||||
expect(result).toContain("listening on :3000")
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
expect(logFilePath).toBeTruthy()
|
||||
|
||||
// The handle is unregistered once the tool call returns.
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
|
||||
// Output emitted after detach is appended to the log file, and
|
||||
// completion closes it out with a completion marker.
|
||||
emitLine("compiled successfully")
|
||||
complete({ exitCode: 0 })
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain("listening on :3000") // buffered lines flushed at detach
|
||||
expect(log).toContain("compiled successfully") // streamed after detach
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
|
||||
it("detaches each parallel command into its own log file", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const first = createControllableTerminalProcess()
|
||||
const second = createControllableTerminalProcess()
|
||||
|
||||
const firstPromise = executeForeground(
|
||||
"first-cmd",
|
||||
"/workspace",
|
||||
createFakeTerminalManager(first.process),
|
||||
100_000,
|
||||
undefined,
|
||||
coordinator,
|
||||
)
|
||||
const secondPromise = executeForeground(
|
||||
"second-cmd",
|
||||
"/workspace",
|
||||
createFakeTerminalManager(second.process),
|
||||
100_000,
|
||||
undefined,
|
||||
coordinator,
|
||||
)
|
||||
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
first.emitLine("first output")
|
||||
second.emitLine("second output")
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(2)
|
||||
const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
const firstLog = /redirected to this file[^:]*: (.+)$/m.exec(firstResult)?.[1]?.trim()
|
||||
const secondLog = /redirected to this file[^:]*: (.+)$/m.exec(secondResult)?.[1]?.trim()
|
||||
expect(firstLog).toBeTruthy()
|
||||
expect(secondLog).toBeTruthy()
|
||||
expect(firstLog).not.toBe(secondLog)
|
||||
expect(coordinator.isRunning).toBe(false)
|
||||
|
||||
first.complete()
|
||||
second.complete()
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return (
|
||||
fs.readFileSync(firstLog!, "utf8").includes("[Command completed]") &&
|
||||
fs.readFileSync(secondLog!, "utf8").includes("[Command completed]")
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(fs.readFileSync(firstLog!, "utf8")).toContain("first output")
|
||||
expect(fs.readFileSync(secondLog!, "utf8")).toContain("second output")
|
||||
fs.rmSync(firstLog!, { force: true })
|
||||
fs.rmSync(secondLog!, { force: true })
|
||||
})
|
||||
|
||||
it("stops logging before a line that would exceed the size cap", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
const result = await resultPromise
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
expect(logFilePath).toBeTruthy()
|
||||
|
||||
// A single line larger than the whole cap must not be written at all —
|
||||
// the cap is checked before writing, so one huge line (e.g. a dumped
|
||||
// blob) cannot blow the log far past PROCEED_LOG_MAX_BYTES.
|
||||
emitLine("small line before the blob")
|
||||
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
|
||||
emitLine("after the cap")
|
||||
complete({ exitCode: 0 })
|
||||
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain("small line before the blob")
|
||||
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
|
||||
expect(log).not.toContain("xxxx")
|
||||
expect(log).not.toContain("after the cap")
|
||||
expect(log.length).toBeLessThan(PROCEED_LOG_MAX_BYTES)
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
|
||||
it("applies the size cap to lines buffered before detach", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
const result = await resultPromise
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
expect(logFilePath).toBeTruthy()
|
||||
complete({ exitCode: 0 })
|
||||
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
|
||||
expect(log).not.toContain("xxxx")
|
||||
expect(Buffer.byteLength(log)).toBeLessThanOrEqual(PROCEED_LOG_MAX_BYTES)
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
|
||||
it("freezes the partial output at detach while later output still reaches the log", async () => {
|
||||
const coordinator = new SdkForegroundCommandCoordinator()
|
||||
const { process, emitLine, complete } = createControllableTerminalProcess()
|
||||
const terminalManager = createFakeTerminalManager(process)
|
||||
|
||||
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
|
||||
await waitFor(() => coordinator.isRunning)
|
||||
emitLine("before detach")
|
||||
|
||||
expect(coordinator.proceedWhileRunning()).toBe(1)
|
||||
// Emitted after detach but before the tool call's result is built:
|
||||
// must appear only in the log, never in the partial output.
|
||||
emitLine("after detach")
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result).toContain("before detach")
|
||||
expect(result).not.toContain("after detach")
|
||||
|
||||
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
|
||||
complete({ exitCode: 0 })
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const log = fs.readFileSync(logFilePath!, "utf8")
|
||||
expect(log).toContain("before detach")
|
||||
expect(log).toContain("after detach")
|
||||
fs.rmSync(logFilePath!, { force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,12 +21,16 @@ import {
|
||||
truncateCommandOutput,
|
||||
} from "@cline/core"
|
||||
import type { AgentTool } from "@cline/shared"
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import { TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
|
||||
import { ClineTempManager } from "@services/temp"
|
||||
import * as fs from "fs"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import { MAX_UNRETRIEVED_LINES } from "@/integrations/terminal/constants"
|
||||
import type { ITerminalProcess } from "@/integrations/terminal/types"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getShellForProfile } from "@/utils/shell"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -38,6 +42,14 @@ type VscodeTerminalExecutionMode = "vscodeTerminal" | "backgroundExec"
|
||||
/** Foreground VS Code terminals cannot be forcibly terminated; give long-running commands room to finish. */
|
||||
export const VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Cap on the "Proceed While Running" log file. A detached devserver can log
|
||||
* for days; once the cap is hit we stop appending and note the truncation.
|
||||
* ClineTempManager's periodic cleanup (age + total-size caps) is the backstop
|
||||
* for the files themselves.
|
||||
*/
|
||||
export const PROCEED_LOG_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** Options for creating the VSCode run_commands tool. */
|
||||
export interface VscodeRunCommandsToolOptions {
|
||||
/** Workspace root directory. */
|
||||
@@ -48,6 +60,14 @@ export interface VscodeRunCommandsToolOptions {
|
||||
bashTimeoutMs?: number
|
||||
/** Terminal execution mode captured when this session's tool set is built. */
|
||||
vscodeTerminalExecutionMode?: VscodeTerminalExecutionMode
|
||||
/**
|
||||
* Registry of in-flight foreground executions, owned by SdkController.
|
||||
* When provided, each foreground command can be detached via the
|
||||
* "Proceed While Running" button. Foreground-only: background (SDK
|
||||
* child_process) executions cannot be detached — their abort signal
|
||||
* kills the process tree.
|
||||
*/
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,6 +94,69 @@ export function formatCommandForTerminal(command: ShellCommand): string {
|
||||
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the rest of a detached command's output to a log file: write the
|
||||
* lines buffered so far, then append each further 'line' event until
|
||||
* 'completed'. The write volume is capped at PROCEED_LOG_MAX_BYTES; the
|
||||
* stream is always closed by the 'completed' event, which the terminal
|
||||
* process emits on every exit path (command end, Ctrl+C, terminal closed,
|
||||
* markerless fallback).
|
||||
*/
|
||||
function beginLogCapture(process: ITerminalProcess, terminalCommand: string, existingLines: string[]): string {
|
||||
const logFilePath = ClineTempManager.createTempFilePath("proceed-while-running")
|
||||
const stream = fs.createWriteStream(logFilePath, { flags: "a" })
|
||||
const sizeCapMessage = `[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached; further output is not logged.]`
|
||||
stream.on("error", (error) => {
|
||||
Logger.error(`[VscodeRunCommands] Failed writing proceed-while-running log ${logFilePath}:`, error)
|
||||
})
|
||||
|
||||
let bytesWritten = 0
|
||||
const tryWriteLine = (line: string): boolean => {
|
||||
const chunk = `${line}\n`
|
||||
const chunkBytes = Buffer.byteLength(chunk)
|
||||
if (bytesWritten + chunkBytes > PROCEED_LOG_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
bytesWritten += chunkBytes
|
||||
stream.write(chunk)
|
||||
return true
|
||||
}
|
||||
|
||||
let sizeCapReached = !tryWriteLine(`[Running command: ${terminalCommand}]`)
|
||||
for (const line of existingLines) {
|
||||
if (!tryWriteLine(line)) {
|
||||
sizeCapReached = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const onLine = (line: string): void => {
|
||||
// Check the cap before writing: a single huge line (e.g. a dumped
|
||||
// binary blob or minified bundle) must not blow past the cap.
|
||||
if (!tryWriteLine(line)) {
|
||||
tryWriteLine(sizeCapMessage)
|
||||
process.removeListener("line", onLine)
|
||||
}
|
||||
}
|
||||
if (sizeCapReached) {
|
||||
tryWriteLine(sizeCapMessage)
|
||||
} else {
|
||||
process.on("line", onLine)
|
||||
}
|
||||
process.once("completed", (details) => {
|
||||
process.removeListener("line", onLine)
|
||||
const exitCode = details?.exitCode
|
||||
tryWriteLine(
|
||||
exitCode !== undefined && exitCode !== null
|
||||
? `[Command completed with exit code ${exitCode}]`
|
||||
: "[Command completed]",
|
||||
)
|
||||
stream.end()
|
||||
})
|
||||
|
||||
return logFilePath
|
||||
}
|
||||
|
||||
/** Exported for direct unit testing of the CommandExitError/terminalClosed mapping. */
|
||||
export async function executeForeground(
|
||||
command: ShellCommand,
|
||||
@@ -81,6 +164,7 @@ export async function executeForeground(
|
||||
terminalManager: VscodeTerminalManager,
|
||||
maxOutputChars: number,
|
||||
abortSignal?: AbortSignal,
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator,
|
||||
): Promise<string> {
|
||||
const terminalCommand = formatCommandForTerminal(command)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd)
|
||||
@@ -100,7 +184,7 @@ export async function executeForeground(
|
||||
// truncateCommandOutput's own head/tail strategy below — since build/test
|
||||
// failures usually appear at the end of output.
|
||||
const maxBufferedLines = MAX_UNRETRIEVED_LINES
|
||||
process.on("line", (line: string) => {
|
||||
const bufferLine = (line: string): void => {
|
||||
if (outputLines.length < maxBufferedLines) {
|
||||
outputLines.push(line)
|
||||
} else {
|
||||
@@ -108,7 +192,8 @@ export async function executeForeground(
|
||||
outputLines.push(line)
|
||||
droppedLines++
|
||||
}
|
||||
})
|
||||
}
|
||||
process.on("line", bufferLine)
|
||||
|
||||
// Handle abort signal
|
||||
if (abortSignal) {
|
||||
@@ -121,8 +206,33 @@ export async function executeForeground(
|
||||
process.once("continue", cleanupAbortListener)
|
||||
}
|
||||
|
||||
// Wait for completion
|
||||
await process
|
||||
// "Proceed While Running": register a per-invocation handle so the user
|
||||
// can detach this command. Detaching redirects the remaining output to a
|
||||
// log file and resolves the awaited promise; the command keeps running in
|
||||
// the user's terminal (and the terminal stays busy until it completes).
|
||||
let detachedLogFilePath: string | undefined
|
||||
const unregister = foregroundCommands?.register({
|
||||
detach: () => {
|
||||
if (detachedLogFilePath !== undefined) {
|
||||
return
|
||||
}
|
||||
detachedLogFilePath = beginLogCapture(process, terminalCommand, outputLines)
|
||||
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING, "vscode")
|
||||
// detach() flushes any partial line (reaching both bufferLine and
|
||||
// the log) before resolving the awaited promise. After that the
|
||||
// partial output is final: stop buffering so the remaining
|
||||
// (log-only) output doesn't mutate outputLines while it's read.
|
||||
process.detach()
|
||||
process.removeListener("line", bufferLine)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
// Wait for completion (or detach, which also resolves the promise)
|
||||
await process
|
||||
} finally {
|
||||
unregister?.()
|
||||
}
|
||||
if (abortSignal?.aborted) {
|
||||
throw new Error("Command execution aborted")
|
||||
}
|
||||
@@ -135,6 +245,14 @@ export async function executeForeground(
|
||||
maxChars: maxOutputChars,
|
||||
})
|
||||
|
||||
if (detachedLogFilePath !== undefined) {
|
||||
return [
|
||||
"The user chose to proceed while the command is still running in their terminal.",
|
||||
`This is partial output; further output is being redirected to this file, which you can read to check progress: ${detachedLogFilePath}`,
|
||||
output.length > 0 ? `Output so far:\n${output}` : "No output so far.",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const completionDetails = process.getCompletionDetails?.()
|
||||
|
||||
// A terminal closed mid-command has no exit code and no reliable output —
|
||||
@@ -240,6 +358,13 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
if (!terminalManager) {
|
||||
terminalManager = getTerminalManager()
|
||||
}
|
||||
return await executeForeground(command, commandCwd || cwd, terminalManager, MAX_COMMAND_OUTPUT_CHARS, context.signal)
|
||||
return await executeForeground(
|
||||
command,
|
||||
commandCwd || cwd,
|
||||
terminalManager,
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
context.signal,
|
||||
options.foregroundCommands,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type AgentTool, type AgentToolContext, createTool } from "@cline/shared
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { createVscodeRunCommandsTool, VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS } from "./vscode-run-commands-tool"
|
||||
|
||||
interface McpToolDescriptor {
|
||||
@@ -124,6 +125,8 @@ export interface VscodeExtraToolsOptions {
|
||||
getTerminalManager?: () => VscodeTerminalManager
|
||||
/** Current VS Code terminal execution mode, captured when the session tools are built. */
|
||||
vscodeTerminalExecutionMode?: "vscodeTerminal" | "backgroundExec"
|
||||
/** Registry of in-flight foreground executions for "Proceed While Running". */
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
}
|
||||
|
||||
export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExtraToolsOptions): Promise<AgentTool[]> {
|
||||
@@ -159,6 +162,7 @@ export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExt
|
||||
getTerminalManager: options.getTerminalManager,
|
||||
bashTimeoutMs: executionMode === "vscodeTerminal" ? VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS : undefined,
|
||||
vscodeTerminalExecutionMode: executionMode,
|
||||
foregroundCommands: options.foregroundCommands,
|
||||
}),
|
||||
)
|
||||
Logger.log(
|
||||
|
||||
@@ -36,9 +36,10 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
|
||||
import { createVscodeExtraTools } from "./vscode-runtime-builder"
|
||||
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
|
||||
|
||||
export interface VscodeSessionHostOptions {
|
||||
mcpHub: McpHub
|
||||
@@ -75,6 +76,8 @@ export interface VscodeSessionHostOptions {
|
||||
* with a custom tool that supports foreground/background terminal execution.
|
||||
*/
|
||||
getTerminalManager?: () => VscodeTerminalManager
|
||||
/** Registry of in-flight foreground executions for "Proceed While Running". */
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator
|
||||
}
|
||||
|
||||
export class VscodeSessionHost implements SdkSessionHost {
|
||||
@@ -133,6 +136,7 @@ export class VscodeSessionHost implements SdkSessionHost {
|
||||
cwd: inputWithRemoteConfig.config.cwd,
|
||||
getTerminalManager: options.getTerminalManager,
|
||||
vscodeTerminalExecutionMode: getEffectiveTerminalExecutionMode(requestedTerminalExecutionMode),
|
||||
foregroundCommands: options.foregroundCommands,
|
||||
})
|
||||
return {
|
||||
...inputWithRemoteConfig,
|
||||
|
||||
@@ -124,6 +124,7 @@ export class WebviewGrpcBridge {
|
||||
stateManager,
|
||||
mcpHub: undefined,
|
||||
backgroundCommandRunning: false,
|
||||
foregroundCommandRunning: false,
|
||||
backgroundCommandTaskId: undefined,
|
||||
})
|
||||
await sendStateUpdate(state)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user