mirror of
https://github.com/cline/cline.git
synced 2026-09-16 06:32:31 +08:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6173bad65e | ||
|
|
cf710d76bf | ||
|
|
9b31692fa0 | ||
|
|
d3e32500ae | ||
|
|
b0cb179a06 | ||
|
|
f57c7944ec | ||
|
|
e450cbf5dd | ||
|
|
830ef5d288 | ||
|
|
723ee3b610 | ||
|
|
75e1cc7a6f | ||
|
|
96f8fbf671 | ||
|
|
211cc035bd | ||
|
|
1e9e3c7a3e | ||
|
|
e543c692c7 | ||
|
|
e34e794624 | ||
|
|
8d078f59bd | ||
|
|
94f897f559 | ||
|
|
4b529f5f81 | ||
|
|
978155814e | ||
|
|
055210a2bf | ||
|
|
8e68b14673 | ||
|
|
4061dda034 | ||
|
|
b93a8cd442 | ||
|
|
72561771a7 | ||
|
|
123477dcd9 | ||
|
|
2ac20c647c | ||
|
|
316efc4521 | ||
|
|
b0ee2cc80a | ||
|
|
a063317218 | ||
|
|
6d10f363b3 | ||
|
|
ab0bc93182 | ||
|
|
22ded04bc4 | ||
|
|
edaab58716 | ||
|
|
0ec999b31a | ||
|
|
3845be53b3 | ||
|
|
8014a05088 | ||
|
|
c7850423f1 | ||
|
|
ed5f3031b0 | ||
|
|
3f38bd516f | ||
|
|
be8c16b0ae | ||
|
|
0074b7222f | ||
|
|
ce81bc6855 | ||
|
|
e0803124b2 | ||
|
|
644e841737 | ||
|
|
5c6753e6d7 | ||
|
|
fdcc5367dc | ||
|
|
901fdbc5cb | ||
|
|
69e3149f5f | ||
|
|
3a3d0c1bc3 | ||
|
|
0746ea72bf | ||
|
|
f0d5ede555 | ||
|
|
ed821a6456 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/tuistory
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -37,6 +37,8 @@ All three publish paths gate on tests before publishing: nightly and ab-package
|
||||
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
|
||||
```
|
||||
|
||||
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
|
||||
|
||||
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
|
||||
|
||||
```bash
|
||||
@@ -62,7 +64,7 @@ All three publish paths gate on tests before publishing: nightly and ab-package
|
||||
|
||||
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
|
||||
|
||||
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. A run left `waiting` on environment approval blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
|
||||
## Stable release (combined A/B VSIX) — the current stable path
|
||||
|
||||
@@ -92,14 +94,13 @@ Release prep on `main` (PR, not direct push):
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
|
||||
# publish=false builds an installable .vsix artifact without publishing, but the
|
||||
# package job still requires the same Publish environment approval — an
|
||||
# unapproved rehearsal sits in `waiting` and blocks that version's concurrency
|
||||
# group (rule 5).
|
||||
# publish=false builds an installable .vsix artifact without publishing and
|
||||
# needs NO environment approval — the ungated build job uploads the artifact
|
||||
# and the run completes.
|
||||
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
|
||||
```
|
||||
|
||||
Both test suites run first (no approval needed); the gated `package` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
@@ -107,22 +108,27 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1).
|
||||
2. This workflow does **not** tag or create a GitHub release — do it manually:
|
||||
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
|
||||
|
||||
```bash
|
||||
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
|
||||
```
|
||||
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<curated notes from CHANGELOG>"
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
|
||||
```
|
||||
|
||||
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
### Known caveats of this path
|
||||
|
||||
- **Marketplace only** — no Open VSX step (both standalone workflows have one). Open VSX users stay on the last standalone version until a standalone publish or the cutover.
|
||||
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
|
||||
- A red run can still mean a successful publish on paths that tag (see Gotchas).
|
||||
|
||||
@@ -161,7 +167,7 @@ When the next bundle has held at 100% long enough to trust:
|
||||
|
||||
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
|
||||
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
|
||||
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX (this also heals the Open VSX gap).
|
||||
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX.
|
||||
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
|
||||
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
|
||||
6. Update this skill: delete the combined-era sections and keep the standalone flow.
|
||||
@@ -172,5 +178,5 @@ When the next bundle has held at 100% long enough to trust:
|
||||
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
|
||||
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
|
||||
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package) block their version's concurrency group.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group.
|
||||
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: tuistory
|
||||
description: |
|
||||
Drive and test terminal apps (especially the Cline CLI TUI in apps/cli) through tuistory — named background PTY sessions that agents can read, wait on, snapshot, screenshot, and type into. Like Playwright/tmux for terminals, with reactive waiting instead of blind `sleep`.
|
||||
|
||||
Use this skill when you need to:
|
||||
- Manually test or reproduce bugs in the interactive Cline TUI (`bun run cli -i`) from a headless environment
|
||||
- Run a dev server or any long-lived/interactive process in the background without hanging your tool call
|
||||
- Write or extend Playwright-style e2e tests for the TUI (`bun run test:e2e:tuistory` in apps/cli)
|
||||
- Capture text snapshots or styled PNG screenshots of a TUI screen as evidence
|
||||
---
|
||||
|
||||
# tuistory
|
||||
|
||||
[tuistory](https://github.com/remorses/tuistory) wraps any terminal command in a named background PTY session backed by a Ghostty terminal emulator. Agents interact with the session via short CLI calls that return instantly; humans can `tuistory attach` to the same session to watch or intervene. No real terminal or display (`DISPLAY`) is needed — it works fully headless, which makes it the preferred way for cloud agents to exercise the Cline TUI.
|
||||
|
||||
It is installed as a devDependency of `@cline/cli`, so the pinned binary resolves when you run from `apps/cli`:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
bunx tuistory --help # source of truth for commands, options, and syntax
|
||||
```
|
||||
|
||||
For full upstream docs: `curl -s https://raw.githubusercontent.com/remorses/tuistory/refs/heads/main/README.md`
|
||||
|
||||
## Driving the Cline TUI headlessly
|
||||
|
||||
Launch the TUI in an isolated environment so you don't touch real user config (`~/.cline`):
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
DATA_DIR=$(mktemp -d) && HOME_DIR=$(mktemp -d)
|
||||
bunx tuistory -s cline --cols 120 --rows 36 \
|
||||
--env HOME=$HOME_DIR --env CLINE_DATA_DIR=$DATA_DIR \
|
||||
--env CLINE_DISABLE_CLINE_PASS_NOTICE=1 --env CLINE_TELEMETRY_DISABLED=1 \
|
||||
-- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
|
||||
```
|
||||
|
||||
The dummy `-k test-key` renders the full chat UI; only an actual agent turn would fail. For recorded LLM turns, use the VCR cassettes described in `apps/cli/src/tests/helpers/env.ts` (`CLINE_VCR=playback` + `CLINE_VCR_CASSETTE`). Real turns need a provider credential (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`).
|
||||
|
||||
Then use an **observe → act → observe** loop:
|
||||
|
||||
```bash
|
||||
# Wait reactively for the chat view — never use sleep
|
||||
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
|
||||
|
||||
# Act, then always observe the resulting screen state
|
||||
bunx tuistory -s cline type "/settings"
|
||||
bunx tuistory -s cline snapshot --trim
|
||||
bunx tuistory -s cline press enter
|
||||
bunx tuistory -s cline snapshot --trim
|
||||
|
||||
# Styled PNG of the current screen (prints the file path) — good for artifacts
|
||||
bunx tuistory -s cline screenshot
|
||||
|
||||
# Full raw output stream (snapshot shows only the visible screen)
|
||||
bunx tuistory read -s cline --all
|
||||
|
||||
# Tear down a session YOU started (double Ctrl+C exits the TUI cleanly)
|
||||
bunx tuistory -s cline press ctrl c
|
||||
bunx tuistory -s cline press ctrl c
|
||||
bunx tuistory -s cline close
|
||||
```
|
||||
|
||||
## Background processes (instead of tmux)
|
||||
|
||||
```bash
|
||||
bunx tuistory -s my-server -- bun run dev:sidecar # returns immediately
|
||||
bunx tuistory -s my-server wait "/listening|ready/i" --timeout 30000
|
||||
bunx tuistory read -s my-server # new output since last read
|
||||
bunx tuistory -s my-server restart # after code changes
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Options before `--`, command after.** Everything after the first `--` is passed verbatim to the child: `tuistory -s name --cols 150 -- bun src/index.ts` is correct.
|
||||
- **Snapshot after every action.** TUIs are stateful; dialogs and errors can render over the view you expect. `snapshot` reflects what the user actually sees (occluded text does not count), unlike grepping the raw stream.
|
||||
- **Wait, never sleep.** `wait "text"` / `wait "/regex/i"` (case-sensitive by default) reacts as fast as the terminal updates; `wait-idle` when you don't know what to expect. Always pass `--timeout`.
|
||||
- **Keys land instantly.** Unlike sleep-based scripts, a queued second keypress can leak into the next view (e.g. one Enter both accepts a slash completion and submits it).
|
||||
- **Never close a session you didn't start.** Sessions are shared with humans (`tuistory attach -s name`) and other agents. Default to leaving sessions running; use `read`/`wait`/`snapshot` to inspect without disrupting.
|
||||
- `--cols`/`--rows` affect TUI layout (assertions are width-sensitive); `--pixel-ratio 2` gives sharper screenshots.
|
||||
|
||||
## Writing e2e tests with the library API
|
||||
|
||||
`apps/cli/src/cli.tuistory.e2e.test.ts` (run: `bun run test:e2e:tuistory`) is the reference. The programmatic API runs in-process — no daemon:
|
||||
|
||||
```ts
|
||||
import { launchTerminal } from "tuistory";
|
||||
|
||||
const session = await launchTerminal({
|
||||
command: "bun",
|
||||
args: ["src/index.ts", "--provider", "anthropic", "-k", "test-key"],
|
||||
cwd: cliRoot,
|
||||
env: isolatedEnv, // see createCliEnv() in the reference test
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
waitForDataTimeout: 30_000, // CLI cold start compiles a large TS graph
|
||||
});
|
||||
|
||||
await session.waitForText("What can I do for you?", { timeout: 30_000 });
|
||||
const screen = await session.text({ trimEnd: true }); // emulated screen state
|
||||
await session.type("/settings");
|
||||
await session.press("enter");
|
||||
session.close(); // always close in test teardown
|
||||
```
|
||||
|
||||
Screen-state assertions can check that stale UI is *gone* (`expect(screen).not.toContain(...)`), which stream-grepping harnesses cannot. `session.text({ only: { bold: true } })` filters by style; `session.read()` returns the raw stream since the last read.
|
||||
@@ -5,6 +5,11 @@ name: ext-vscode-ab-package
|
||||
# `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.
|
||||
#
|
||||
# Job layout: cheap input gates (preflight) and the two bundle test suites run
|
||||
# ungated; the build job packages the VSIX with no environment attached, so
|
||||
# publish=false rehearsals complete without any approval; only the publish job
|
||||
# — Marketplace + Open VSX + bookkeeping — waits on the `publish` environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -24,7 +29,7 @@ on:
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -37,18 +42,80 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Input gates that need no checkout: fail in seconds — before the test
|
||||
# suites, the ~20-minute build, and the environment approval — instead of
|
||||
# at publish time.
|
||||
preflight:
|
||||
name: Validate inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The input reaches the shell ONLY via env here (never inline
|
||||
# expression interpolation, which is evaluated before bash runs and
|
||||
# would allow script injection from the dispatch form). Because
|
||||
# every later job `needs` preflight, passing this regex is what
|
||||
# makes the plain-string `${{ inputs.version }}` interpolations
|
||||
# downstream safe.
|
||||
- name: Validate version format
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be plain X.Y.Z with no leading 'v' and no suffix (got '$VERSION')."
|
||||
echo "It is stamped verbatim into the union manifest and both bundle manifests."
|
||||
exit 1
|
||||
fi
|
||||
echo "Version format ok: $VERSION"
|
||||
|
||||
# The reusable bun suite tests the dispatch revision (main), so
|
||||
# publishing any other next-ref would ship an untested bundle.
|
||||
# Build-only runs (publish=false) may still use arbitrary next-refs
|
||||
# for artifact rehearsals.
|
||||
- name: Refuse to publish an untested next-ref
|
||||
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
|
||||
run: |
|
||||
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
|
||||
exit 1
|
||||
|
||||
# Marketplace versions are monotonic and cannot be unpublished:
|
||||
# every publish must exceed the highest version ever published to
|
||||
# the claude-dev listing FROM ANY BRANCH (combined stable or legacy
|
||||
# hotfix). The publish job re-checks right before publishing — the
|
||||
# environment-approval wait can last days and a legacy hotfix can
|
||||
# land in between. Keep both copies of this check in sync.
|
||||
- name: Verify version exceeds the live Marketplace version
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
|
||||
if [[ -z "$LIVE" ]]; then
|
||||
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
|
||||
exit 1
|
||||
fi
|
||||
node -e '
|
||||
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (next[i] > live[i]) process.exit(0);
|
||||
if (next[i] < live[i]) break;
|
||||
}
|
||||
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
|
||||
process.exit(1);
|
||||
' "$VERSION" "$LIVE"
|
||||
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
|
||||
|
||||
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
|
||||
# standalone publish paths (nightly gates on the bun suite via the same
|
||||
# reusable workflow; the legacy publish inlines the npm suite). The gated
|
||||
# `package` job requests its `publish` environment approval only after both
|
||||
# suites pass.
|
||||
# reusable workflow; the legacy publish inlines the npm suite).
|
||||
#
|
||||
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
|
||||
# DISPATCH revision — main's tip at dispatch, since this workflow is only
|
||||
# dispatched from main — not `next-ref`. The package job therefore pins the
|
||||
# dispatched from main — not `next-ref`. The build job therefore pins the
|
||||
# default next-ref checkout to that same revision (tested == built) and
|
||||
# refuses publish=true for any other next-ref; build-only artifact runs may
|
||||
# still build untested refs.
|
||||
# preflight refuses publish=true for any other next-ref; build-only artifact
|
||||
# runs may still build untested refs.
|
||||
test-next:
|
||||
name: Test next (SDK) bundle
|
||||
permissions:
|
||||
@@ -62,11 +129,10 @@ jobs:
|
||||
test-legacy:
|
||||
name: Test legacy bundle
|
||||
runs-on: ubuntu-latest
|
||||
# The tested revision, exported so the package job builds EXACTLY what
|
||||
# The tested revision, exported so the build job builds EXACTLY what
|
||||
# this suite ran against. legacy-ref is a mutable branch name and the
|
||||
# package job starts much later (test phase + environment-approval wait,
|
||||
# potentially days) — re-resolving the name there could pick up commits
|
||||
# this gate never saw.
|
||||
# build job starts later — re-resolving the name there could pick up
|
||||
# commits this gate never saw.
|
||||
outputs:
|
||||
tested-sha: ${{ steps.rev.outputs.sha }}
|
||||
defaults:
|
||||
@@ -121,23 +187,11 @@ jobs:
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
package:
|
||||
build:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
needs: [test-next, test-legacy]
|
||||
needs: [preflight, test-next, test-legacy]
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
# Refuse to publish a next bundle the test-next gate did not cover.
|
||||
# The reusable suite tests the dispatch revision (main), so publishing
|
||||
# any other next-ref would ship an untested bundle. Build-only runs
|
||||
# (publish=false) may still use arbitrary next-refs for artifact
|
||||
# rehearsals.
|
||||
- name: Refuse to publish an untested next-ref
|
||||
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
|
||||
run: |
|
||||
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
|
||||
exit 1
|
||||
|
||||
# For the default next-ref (main), pin the checkout to the exact
|
||||
# revision the test-next gate ran against: a moving branch name could
|
||||
# otherwise drift past the tested commit during the test phase.
|
||||
@@ -148,6 +202,21 @@ jobs:
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
# Fail fast (before the ~20-min build) if a real publish is missing
|
||||
# its changelog entry — same contract the standalone publish
|
||||
# workflows enforce. Build-only rehearsals are exempt.
|
||||
- name: Verify changelog entry
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: next-src
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ github.event.inputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing (found '$FIRST_HEADING')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ github.event.inputs.version }}"
|
||||
|
||||
# Pin to the revision test-legacy actually tested (see that job's
|
||||
# outputs comment) — never re-resolve the mutable branch name here.
|
||||
- name: Checkout legacy source
|
||||
@@ -166,9 +235,12 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# --frozen-lockfile so the built bundle resolves the exact
|
||||
# dependency set the test-next gate ran against (the reusable suite
|
||||
# installs frozen too) — a bare install could silently re-resolve.
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
@@ -177,6 +249,17 @@ jobs:
|
||||
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
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# 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
|
||||
@@ -293,14 +376,176 @@ jobs:
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish to Marketplace and Open VSX
|
||||
needs: build
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
# contents: write is required by the post-publish bookkeeping (tag +
|
||||
# GitHub Release), mirroring the standalone publish workflows.
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# The built next revision: preflight refused publish=true for any
|
||||
# next-ref other than main, and the build job pinned main to the
|
||||
# dispatch SHA — so github.sha IS the published commit. Used for the
|
||||
# changelog, the release tag, and the previous-tag lookup.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Download VSIX artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
# Re-check monotonicity at the last moment: the environment-approval
|
||||
# wait can last days, and a legacy hotfix published in the meantime
|
||||
# would otherwise be silently superseded by this older code line.
|
||||
# Keep in sync with the preflight copy of this check.
|
||||
- name: Re-verify version exceeds the live Marketplace version
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
|
||||
if [[ -z "$LIVE" ]]; then
|
||||
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
|
||||
exit 1
|
||||
fi
|
||||
node -e '
|
||||
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (next[i] > live[i]) process.exit(0);
|
||||
if (next[i] < live[i]) break;
|
||||
}
|
||||
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
|
||||
process.exit(1);
|
||||
' "$VERSION" "$LIVE"
|
||||
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
|
||||
|
||||
# Both PATs are verified BEFORE the first irreversible publish so a
|
||||
# missing Open VSX token can't strand us half-published. The two
|
||||
# registries are separate steps: if Open VSX fails after the
|
||||
# Marketplace accepted the VSIX, the run goes red (so the operator
|
||||
# notices Open VSX lagged) but the bookkeeping below still runs —
|
||||
# it is keyed off the Marketplace outcome, which is what "shipped"
|
||||
# means for this listing.
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
id: publish_marketplace
|
||||
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
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish to Open VSX."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Publish to Open VSX
|
||||
working-directory: staging
|
||||
env:
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: npx ovsx publish --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
|
||||
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
|
||||
# Mirrors the standalone publish workflows. Every step here is
|
||||
# continue-on-error, and gated on the MARKETPLACE outcome rather
|
||||
# than plain step ordering: the Marketplace publish already
|
||||
# happened, so bookkeeping must still run when only the Open VSX
|
||||
# step failed, and a red run after a successful publish is exactly
|
||||
# the confusion the nightly workflow taught us to avoid (tag pushes
|
||||
# fail whenever the built commit touches .github/workflows/** — no
|
||||
# grantable permission fixes that; push the tag manually in that
|
||||
# case, see the publish-extension skill).
|
||||
|
||||
- name: Extract changelog entry
|
||||
id: changelog
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
{
|
||||
echo "content<<CHANGELOG_EOF"
|
||||
echo "$CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# ls-remote needs no local tag objects; take the highest v* tag
|
||||
# below the one being released.
|
||||
PREV=$(git ls-remote --tags origin 'v*' \
|
||||
| awk -F/ '{print $NF}' | grep -v '\^{}' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vx "v${{ github.event.inputs.version }}" \
|
||||
| sort -V | tail -1)
|
||||
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push release tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
TAG="v${{ github.event.inputs.version }}"
|
||||
git tag "$TAG" HEAD
|
||||
git push origin "refs/tags/$TAG"
|
||||
echo "Pushed $TAG at $(git rev-parse HEAD)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ github.event.inputs.version }}
|
||||
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline v${{ github.event.inputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline v${{ github.event.inputs.version }}*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
|
||||
|
||||
+128
@@ -1,5 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Show which extension variant is active — "Legacy" or "Next" — next to the version in the settings About page, in both bundles of the combined rollout package.
|
||||
|
||||
## [4.1.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove vestigial MCP server-key machinery from McpHub — native MCP tool calls now route by server name instead of a random in-memory uid, so routing survives restarts and server list changes.
|
||||
|
||||
## [4.1.0]
|
||||
|
||||
### Changed
|
||||
|
||||
- Convert the stable extension to a combined A/B package: one VSIX containing both the current (legacy) extension and the new SDK-based extension, plus a loader that activates exactly one per window via a staged remote rollout. For nearly all users nothing changes — the loader activates the same extension as 4.0.12; a small percentage (starting at 1%) is gradually opted into the SDK-based extension. If the new extension fails to activate, the loader falls back to the current one in the same window. Settings and credentials are shared between the two.
|
||||
|
||||
## [4.0.12]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
|
||||
|
||||
## [4.0.11]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
|
||||
- Add Moonshot Kimi K3 support.
|
||||
- Include the host plugin version in telemetry events.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
|
||||
- Enable native tool calling for Kimi K3 models, fixing empty responses.
|
||||
|
||||
## [4.0.10]
|
||||
|
||||
### Added
|
||||
|
||||
- Add telemetry to track when Cline reaches the consecutive mistake limit.
|
||||
|
||||
## [4.0.9]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.6 ChatGPT subscription models.
|
||||
|
||||
### Changed
|
||||
|
||||
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
|
||||
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
|
||||
|
||||
## [4.0.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
|
||||
|
||||
## [4.0.7]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
|
||||
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
|
||||
- Remove the Cline model picker recommendation copy.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove all references to GLM 5.1.
|
||||
|
||||
## [4.0.6]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Generalize the model capability warning so it applies more broadly.
|
||||
|
||||
## [4.0.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
|
||||
|
||||
## [4.0.4]
|
||||
|
||||
### Changed
|
||||
|
||||
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
|
||||
|
||||
## [4.0.3]
|
||||
|
||||
### Changed
|
||||
|
||||
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
|
||||
|
||||
## [4.0.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
|
||||
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
|
||||
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
|
||||
- Fix environment variable replacement in the webview.
|
||||
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
|
||||
|
||||
## [4.0.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.48
|
||||
|
||||
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
|
||||
- Connector threads (Slack, Discord, Telegram, Linear, Google Chat, WhatsApp) now recover when the session they were bound to is gone — the stale binding is dropped and the turn replays against a new session, instead of failing with "session not found" until `threads.json` is edited by hand
|
||||
- `cline --help` now reports the real default `--config` and `--data-dir` paths
|
||||
- The per-server `timeout` in `cline_mcp_settings.json` is now honored for `initialize`, `tools/list`, and `tools/call`, so slow MCP servers no longer fail against a hardcoded 5s limit (from SDK v0.0.67)
|
||||
- Reasoning controls are now routed from the models.dev catalog across providers, with clamped budgets and correct per-provider encoding (from SDK v0.0.67)
|
||||
- OpenRouter now defaults to `anthropic/claude-sonnet-5` (from SDK v0.0.67)
|
||||
- Fixed the China and international endpoint toggles being ignored for Qwen, Moonshot, and Z AI (from SDK v0.0.67)
|
||||
- Legacy API keys are now migrated for every secret-backed provider (from SDK v0.0.67)
|
||||
- Legacy OpenAI Compatible model-info overrides now survive into the seeded `models.json` (from SDK v0.0.67)
|
||||
- Fixed auto-compaction state being rejected as stale, which added a redundant summarizer call on every turn past the compaction trigger (from SDK v0.0.67)
|
||||
- Fixed checkpoint restores across session resumes (from SDK v0.0.67)
|
||||
- Tool calls that pass line numbers as strings (`insert_line`, `read_files` bounds) are now accepted instead of erroring (from SDK v0.0.67)
|
||||
- A legacy single-file `.clinerules` no longer aborts the config scan (from SDK v0.0.67)
|
||||
- Plugins can now emit telemetry through `ctx.telemetry` (from SDK v0.0.67)
|
||||
|
||||
## 3.0.47
|
||||
|
||||
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
|
||||
|
||||
@@ -339,6 +339,9 @@ bun run test:e2e:interactive
|
||||
# TUI-specific E2E tests (uses @microsoft/tui-test)
|
||||
bun run test:e2e:cli:tui
|
||||
|
||||
# TUI E2E tests driven through tuistory (PTY + Ghostty terminal emulator)
|
||||
bun run test:e2e:tuistory
|
||||
|
||||
# Type checking
|
||||
bun run typecheck
|
||||
|
||||
@@ -364,6 +367,34 @@ bun run dev -- --interactive --config /tmp/cline-test
|
||||
|
||||
Or set `CLINE_FORCE_ONBOARDING=1` to force the onboarding view regardless of existing config.
|
||||
|
||||
### Manually testing the TUI (agents / headless environments)
|
||||
|
||||
[tuistory](https://github.com/remorses/tuistory) is installed as a devDependency. It wraps the TUI in a named background PTY session that can be scripted from a plain shell — no real terminal or display needed. This is the preferred way for AI agents (or anyone in a headless environment) to poke at the interactive TUI:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
|
||||
# Launch the TUI in a background session
|
||||
bunx tuistory -s cline --cols 120 --rows 36 -- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
|
||||
|
||||
# Wait reactively for the chat view (no sleep guessing)
|
||||
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
|
||||
|
||||
# Interact and inspect
|
||||
bunx tuistory -s cline type "/settings"
|
||||
bunx tuistory -s cline press enter
|
||||
bunx tuistory -s cline snapshot --trim # current screen as text
|
||||
bunx tuistory -s cline screenshot # current screen as a styled PNG
|
||||
|
||||
# A human can watch/drive the same session from another terminal
|
||||
tuistory attach -s cline
|
||||
|
||||
# Tear down
|
||||
bunx tuistory -s cline close
|
||||
```
|
||||
|
||||
The same engine powers the `test:e2e:tuistory` vitest suite (`src/cli.tuistory.e2e.test.ts`), which uses the programmatic `launchTerminal()` API for assertions against the emulated screen.
|
||||
|
||||
### Adding a new TUI component
|
||||
|
||||
1. Create a `.tsx` file in `src/tui/components/`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.47",
|
||||
"version": "3.0.48",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -62,6 +62,7 @@
|
||||
"test:unit": "vitest run --config vitest.config.ts",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:e2e:interactive": "vitest run --config vitest.interactive.e2e.config.ts",
|
||||
"test:e2e:tuistory": "vitest run --config vitest.tuistory.e2e.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"test:e2e:cli:tui": "cd src/tests && tui-test",
|
||||
"link": "bun unlink && bun link"
|
||||
@@ -99,8 +100,9 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@microsoft/tui-test": "^0.0.2",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/react": "19.2.14",
|
||||
"vitest": "^4.0.18",
|
||||
"@types/bun": "^1.3.10"
|
||||
"tuistory": "^0.10.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
+282
-34
@@ -7,6 +7,8 @@ import type {
|
||||
ContentBlock,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
@@ -28,11 +30,12 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { isLikelyAuthError, type Message } from "@cline/shared";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { subscribeToAgentEvents } from "../runtime/session-events";
|
||||
import { createCliCore } from "../session/session";
|
||||
import { isClineOrgIndividualInferenceSubscriptionErrorMessage } from "../utils/cline-pass-errors";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import type { Config } from "../utils/types";
|
||||
@@ -43,8 +46,19 @@ import {
|
||||
authenticateAcpProvider,
|
||||
isAcpAuthMethodId,
|
||||
} from "./auth";
|
||||
import { requestAcpToolApproval } from "./permissions";
|
||||
import {
|
||||
buildOrganizationConfigOption,
|
||||
fetchClineOrganizations,
|
||||
getAcpOrgSubscriptionMessage,
|
||||
ORGANIZATION_CONFIG_ID,
|
||||
PERSONAL_ACCOUNT_VALUE,
|
||||
switchClineOrganization,
|
||||
usesClineAccount,
|
||||
} from "./organizations";
|
||||
import { requestAcpToolApproval } from "./permissions";
|
||||
import { replaySessionHistory } from "./session-load";
|
||||
import {
|
||||
describeAgentError,
|
||||
forwardAgentEvent,
|
||||
sendConfigOptionUpdate,
|
||||
sendCurrentModeUpdate,
|
||||
@@ -69,6 +83,15 @@ interface SessionState {
|
||||
abortController?: AbortController;
|
||||
/** Unsubscribe function for the agent event listener. */
|
||||
unsubscribe?: () => void;
|
||||
/**
|
||||
* Most recent unrecoverable agent error for the in-flight turn.
|
||||
*
|
||||
* The runtime reports fatal failures (bad credentials, subscription
|
||||
* restrictions, provider outages) as an `error` event and still resolves
|
||||
* `send()` normally, so the message has to be stashed here for `prompt()` to
|
||||
* turn into an error response.
|
||||
*/
|
||||
fatalError?: Error;
|
||||
/** Messages to inject into the next session manager for conversation continuity. */
|
||||
pendingInitialMessages?: Message[];
|
||||
}
|
||||
@@ -109,7 +132,7 @@ export class AcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
isSessionReady() {
|
||||
// Require authentication unless an API key is provided via env var.
|
||||
if (!this.authResult && !process.env.CLINE_API_KEY) {
|
||||
// Check for valid persisted credentials from a previous session
|
||||
@@ -119,18 +142,46 @@ export class AcpAgent implements Agent {
|
||||
if (!this.authResult) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
"Call authenticate before creating a session",
|
||||
"Call authenticate before starting a session",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
availableModes() {
|
||||
return [
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description:
|
||||
"Explore the codebase and plan changes without modifying files",
|
||||
},
|
||||
{
|
||||
id: "act",
|
||||
name: "Act",
|
||||
description: "Make changes to the codebase",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
this.isSessionReady();
|
||||
|
||||
const sessionId = randomSessionId();
|
||||
|
||||
const defaultMode = "act";
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
const defaultModelId =
|
||||
process.env.CLINE_MODEL ?? "anthropic/claude-sonnet-4.6";
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
// Model ids are provider-scoped, so the default must come from the
|
||||
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
|
||||
// nothing to `cline`, and vice versa.
|
||||
const defaultModelId = await resolveDefaultModelId(
|
||||
providerId,
|
||||
process.env.CLINE_MODEL,
|
||||
providerModels,
|
||||
);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
@@ -141,7 +192,6 @@ export class AcpAgent implements Agent {
|
||||
currentModelId: defaultModelId,
|
||||
});
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([modelId, info]) => ({
|
||||
modelId,
|
||||
@@ -150,22 +200,13 @@ export class AcpAgent implements Agent {
|
||||
}),
|
||||
);
|
||||
|
||||
const organizationOption =
|
||||
await this.getOrganizationConfigOption(providerId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: [
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description:
|
||||
"Explore the codebase and plan changes without modifying files",
|
||||
},
|
||||
{
|
||||
id: "act",
|
||||
name: "Act",
|
||||
description: "Make changes to the codebase",
|
||||
},
|
||||
],
|
||||
availableModes: this.availableModes(),
|
||||
currentModeId: defaultMode,
|
||||
},
|
||||
models: {
|
||||
@@ -176,10 +217,85 @@ export class AcpAgent implements Agent {
|
||||
await buildProviderConfigOption(providerId),
|
||||
buildModelConfigOption(defaultModelId, providerModels),
|
||||
buildModeConfigOption(defaultMode),
|
||||
...(organizationOption ? [organizationOption] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
this.isSessionReady();
|
||||
|
||||
let session = this.sessions.get(params.sessionId);
|
||||
let messages: Message[];
|
||||
|
||||
if (session?.sessionManager && session.activeSessionId) {
|
||||
// The session is still live in this connection — replay its current
|
||||
// conversation without restarting anything.
|
||||
messages =
|
||||
(await session.sessionManager.readMessages(session.activeSessionId)) ??
|
||||
[];
|
||||
} else {
|
||||
if (!session) {
|
||||
// Provider/model are not persisted per session — a session
|
||||
// loaded on a fresh connection starts from the same defaults
|
||||
// as a new session, with the model resolved against the
|
||||
// provider's own catalog just like newSession.
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
session = {
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers,
|
||||
currentMode: "act",
|
||||
currentProviderId: providerId,
|
||||
currentModelId: await resolveDefaultModelId(
|
||||
providerId,
|
||||
process.env.CLINE_MODEL,
|
||||
providerModels,
|
||||
),
|
||||
};
|
||||
this.sessions.set(params.sessionId, session);
|
||||
}
|
||||
try {
|
||||
messages =
|
||||
(await this.ensureSessionManager(session, params.sessionId, {
|
||||
resume: true,
|
||||
})) ?? [];
|
||||
} catch (error) {
|
||||
this.sessions.delete(params.sessionId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The ACP spec requires the full conversation to be replayed via
|
||||
// session/update notifications before this request resolves.
|
||||
await replaySessionHistory(this.conn, params.sessionId, messages);
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([availableModelId, info]) => ({
|
||||
modelId: availableModelId,
|
||||
name: info.name ?? availableModelId,
|
||||
description: info.description,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
modes: {
|
||||
availableModes: this.availableModes(),
|
||||
currentModeId: session.currentMode,
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId: session.currentModelId,
|
||||
},
|
||||
configOptions: await buildAllConfigOptions(session),
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
@@ -193,6 +309,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const abortController = new AbortController();
|
||||
session.abortController = abortController;
|
||||
session.fatalError = undefined;
|
||||
|
||||
// If cancel() was already called before prompt() started, bail early.
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -242,6 +359,17 @@ export class AcpAgent implements Agent {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// A cancelled turn always reports `cancelled`: the ACP spec
|
||||
// requires agents to convert abort failures into the cancelled stop reason
|
||||
// so clients don't show cancellations as errors.
|
||||
if (stopReason !== "cancelled") {
|
||||
const fatalError = session.fatalError;
|
||||
session.fatalError = undefined;
|
||||
if (fatalError) {
|
||||
throw toAcpPromptError(fatalError);
|
||||
}
|
||||
}
|
||||
|
||||
return { stopReason };
|
||||
}
|
||||
|
||||
@@ -326,16 +454,37 @@ export class AcpAgent implements Agent {
|
||||
// creates a fresh one with the new provider on the next prompt().
|
||||
await this.teardownSessionManager(session);
|
||||
|
||||
// If current model doesn't exist in new provider, reset to first available
|
||||
// Re-resolve the model against the new provider's catalog: keep the
|
||||
// current one when it's offered there too, otherwise fall back to the
|
||||
// provider's declared default rather than whichever model happens to
|
||||
// be listed first (for cline-pass that is an unrelated free model).
|
||||
const providerModels = await Llms.getModelsForProvider(value);
|
||||
const modelIds = Object.keys(providerModels);
|
||||
const fallbackModelId = modelIds[0];
|
||||
if (
|
||||
!modelIds.includes(session.currentModelId) &&
|
||||
fallbackModelId !== undefined
|
||||
) {
|
||||
session.currentModelId = fallbackModelId;
|
||||
session.currentModelId = await resolveDefaultModelId(
|
||||
value,
|
||||
session.currentModelId,
|
||||
providerModels,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case ORGANIZATION_CONFIG_ID: {
|
||||
try {
|
||||
await switchClineOrganization({
|
||||
apiKey: this.accountApiKey,
|
||||
providerSettingsManager: this.providerSettingsManager,
|
||||
organizationId: value === PERSONAL_ACCOUNT_VALUE ? null : value,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = describeAgentError(error);
|
||||
throw RequestError.internalError(
|
||||
{ message },
|
||||
`Failed to switch account: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Restart the backend session so subsequent turns run under the
|
||||
// newly selected account.
|
||||
await this.teardownSessionManager(session);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -370,6 +519,12 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
|
||||
const configOptions = await buildAllConfigOptions(session);
|
||||
const organizationOption = await this.getOrganizationConfigOption(
|
||||
session.currentProviderId,
|
||||
);
|
||||
if (organizationOption) {
|
||||
configOptions.push(organizationOption);
|
||||
}
|
||||
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
|
||||
return { configOptions };
|
||||
}
|
||||
@@ -410,6 +565,25 @@ export class AcpAgent implements Agent {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private get accountApiKey(): string {
|
||||
return process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
}
|
||||
|
||||
private async getOrganizationConfigOption(
|
||||
providerId: string,
|
||||
): Promise<SessionConfigOption | undefined> {
|
||||
if (!usesClineAccount(providerId)) {
|
||||
return undefined;
|
||||
}
|
||||
const organizations = await fetchClineOrganizations({
|
||||
apiKey: this.accountApiKey,
|
||||
providerSettingsManager: this.providerSettingsManager,
|
||||
});
|
||||
return organizations
|
||||
? buildOrganizationConfigOption(organizations)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to restore authentication from persisted provider settings.
|
||||
*
|
||||
@@ -467,13 +641,17 @@ export class AcpAgent implements Agent {
|
||||
* Lazily create and start the session manager for this ACP session.
|
||||
* After the first call the manager persists across prompt() calls so that
|
||||
* conversation history is maintained.
|
||||
*
|
||||
* With `resume: true` the persisted conversation for `acpSessionId` is read
|
||||
* back through the session manager.
|
||||
*/
|
||||
private async ensureSessionManager(
|
||||
session: SessionState,
|
||||
acpSessionId: string,
|
||||
): Promise<void> {
|
||||
options?: { resume?: boolean },
|
||||
): Promise<Message[] | undefined> {
|
||||
if (session.sessionManager) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = await this.buildConfig(session);
|
||||
@@ -488,25 +666,53 @@ export class AcpAgent implements Agent {
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
});
|
||||
|
||||
let initialMessages: Message[] | undefined;
|
||||
if (options?.resume) {
|
||||
initialMessages = await sessionManager
|
||||
.readMessages(acpSessionId)
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!initialMessages || initialMessages.length === 0) {
|
||||
await sessionManager
|
||||
.dispose("acp_load_session_not_found")
|
||||
.catch(() => {});
|
||||
throw RequestError.resourceNotFound(acpSessionId);
|
||||
}
|
||||
} else {
|
||||
initialMessages = session.pendingInitialMessages;
|
||||
session.pendingInitialMessages = undefined;
|
||||
}
|
||||
|
||||
session.unsubscribe = subscribeToAgentEvents(
|
||||
sessionManager,
|
||||
(event: AgentEvent) => {
|
||||
// Remember unrecoverable failures so prompt() can fail the turn.
|
||||
if (event.type === "error" && !event.recoverable) {
|
||||
session.fatalError =
|
||||
event.error instanceof Error
|
||||
? event.error
|
||||
: new Error(describeAgentError(event.error));
|
||||
}
|
||||
forwardAgentEvent(this.conn, acpSessionId, event);
|
||||
},
|
||||
);
|
||||
|
||||
const initialMessages = session.pendingInitialMessages;
|
||||
session.pendingInitialMessages = undefined;
|
||||
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
config,
|
||||
// Persist the core session under the ACP session id so that
|
||||
// session/load can find the conversation by the id the client holds.
|
||||
config: {
|
||||
...config,
|
||||
modelId: session.currentModelId,
|
||||
sessionId: acpSessionId,
|
||||
},
|
||||
interactive: true,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
session.sessionManager = sessionManager;
|
||||
session.activeSessionId = started.sessionId;
|
||||
return initialMessages;
|
||||
}
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
@@ -560,6 +766,48 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDefaultModelId(
|
||||
providerId: string,
|
||||
preferredModelId: string | undefined,
|
||||
providerModels: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const modelIds = Object.keys(providerModels);
|
||||
const preferred = preferredModelId?.trim();
|
||||
if (preferred && modelIds.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
const providerDefault = (await Llms.getProvider(providerId))?.defaultModelId;
|
||||
if (providerDefault && modelIds.includes(providerDefault)) {
|
||||
return providerDefault;
|
||||
}
|
||||
return modelIds[0] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fatal agent error into a JSON-RPC error for the prompt response.
|
||||
*
|
||||
* Credential/subscription problems map to `auth_required` (-32000) so clients
|
||||
* can offer a re-auth affordance rather than just printing text; everything
|
||||
* else is an internal error.
|
||||
*
|
||||
* Classification goes through the shared CLI helpers, which check the error's
|
||||
* type *and* its name/message. That matters because the runtime re-wraps errors
|
||||
* as it forwards them across the event boundary, so `instanceof` alone fails on
|
||||
* the object ACP actually receives.
|
||||
*/
|
||||
function toAcpPromptError(error: Error): RequestError {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
const message = getAcpOrgSubscriptionMessage();
|
||||
return RequestError.internalError({ message }, message);
|
||||
}
|
||||
|
||||
const message = describeAgentError(error);
|
||||
const isAuthProblem = isLikelyAuthError(error);
|
||||
return isAuthProblem
|
||||
? RequestError.authRequired({ message }, message)
|
||||
: RequestError.internalError({ message }, message);
|
||||
}
|
||||
|
||||
async function buildProviderConfigOption(
|
||||
currentProviderId: string,
|
||||
): Promise<SessionConfigOption> {
|
||||
|
||||
@@ -5,9 +5,13 @@ import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
* Supported ACP OAuth provider IDs.
|
||||
*
|
||||
* This list doubles as the set of selectable providers (see
|
||||
* `setSessionConfigOption`)
|
||||
*/
|
||||
export const ACP_AUTH_METHODS = [
|
||||
{ id: "cline", name: "Sign in with Cline" },
|
||||
{ id: "cline-pass", name: "Sign in with ClinePass" },
|
||||
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
|
||||
] as const;
|
||||
|
||||
@@ -30,7 +34,7 @@ async function performOAuthLogin(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
[import("@cline/core"), import("../utils/open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOrganizationConfigOption,
|
||||
PERSONAL_ACCOUNT_VALUE,
|
||||
} from "./organizations";
|
||||
|
||||
describe("buildOrganizationConfigOption", () => {
|
||||
const organizations = [
|
||||
{
|
||||
active: false,
|
||||
memberId: "m-1",
|
||||
name: "Acme Corp",
|
||||
organizationId: "org-1",
|
||||
roles: ["member" as const],
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
memberId: "m-2",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-2",
|
||||
roles: ["admin" as const],
|
||||
},
|
||||
];
|
||||
|
||||
it("lists Personal first plus every organization", () => {
|
||||
const option = buildOrganizationConfigOption({
|
||||
organizations,
|
||||
activeOrganizationId: "org-2",
|
||||
});
|
||||
expect(option.id).toBe("organization");
|
||||
if (option.type !== "select") {
|
||||
throw new Error(`expected a select option, got ${option.type}`);
|
||||
}
|
||||
expect(option.currentValue).toBe("org-2");
|
||||
expect(option.options).toEqual([
|
||||
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
|
||||
{ value: "org-1", name: "Acme Corp" },
|
||||
{ value: "org-2", name: "Cline Bot Inc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects Personal when no organization is active", () => {
|
||||
const option = buildOrganizationConfigOption({
|
||||
organizations,
|
||||
activeOrganizationId: null,
|
||||
});
|
||||
expect(option.currentValue).toBe(PERSONAL_ACCOUNT_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
type ClineAccountOrganization,
|
||||
ClineAccountService,
|
||||
getPersistedProviderApiKey,
|
||||
type ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export const PERSONAL_ACCOUNT_VALUE = "personal";
|
||||
|
||||
export const ORGANIZATION_CONFIG_ID = "organization";
|
||||
|
||||
export function usesClineAccount(providerId: string): boolean {
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
export interface AcpOrganizationState {
|
||||
organizations: ClineAccountOrganization[];
|
||||
/** Active organization id, or null when the personal account is active. */
|
||||
activeOrganizationId: string | null;
|
||||
}
|
||||
|
||||
interface ClineAccountInput {
|
||||
apiKey: string;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}
|
||||
|
||||
// Cline access tokens expire between runs, so account requests resolve
|
||||
// through the refresh-aware OAuth manager. A single shared instance keeps
|
||||
// refreshes single-flight; the refresh token is single-use, so parallel
|
||||
// refreshes would invalidate each other.
|
||||
let oauthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function createAccountService(input: ClineAccountInput): ClineAccountService {
|
||||
const { providerSettingsManager } = input;
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
return new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => {
|
||||
try {
|
||||
oauthTokenManager ??= new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager,
|
||||
});
|
||||
const resolution = await oauthTokenManager.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 (
|
||||
getPersistedProviderApiKey(
|
||||
"cline",
|
||||
providerSettingsManager.getProviderSettings("cline"),
|
||||
) ||
|
||||
input.apiKey ||
|
||||
undefined
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClineOrganizations(
|
||||
input: ClineAccountInput,
|
||||
): Promise<AcpOrganizationState | undefined> {
|
||||
try {
|
||||
const service = createAccountService(input);
|
||||
const organizations = await service.fetchUserOrganizations();
|
||||
if (organizations.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
organizations,
|
||||
activeOrganizationId:
|
||||
organizations.find((org) => org.active)?.organizationId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrganizationConfigOption(
|
||||
state: AcpOrganizationState,
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: ORGANIZATION_CONFIG_ID,
|
||||
name: "Account",
|
||||
description:
|
||||
"The Cline account usage is billed to — your personal account or an organization",
|
||||
category: "account",
|
||||
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
|
||||
options: [
|
||||
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
|
||||
...state.organizations.map((org) => ({
|
||||
value: org.organizationId,
|
||||
name: org.name,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function switchClineOrganization(
|
||||
input: ClineAccountInput & { organizationId: string | null },
|
||||
): Promise<void> {
|
||||
const service = createAccountService(input);
|
||||
await service.switchAccount(input.organizationId);
|
||||
await persistActiveOrganization(input.providerSettingsManager, service);
|
||||
}
|
||||
|
||||
// Re-persist the active organization so headless runs and the hub daemon
|
||||
// attribute telemetry to the right account. Best-effort: the switch itself
|
||||
// already succeeded server-side.
|
||||
async function persistActiveOrganization(
|
||||
manager: ProviderSettingsManager,
|
||||
service: ClineAccountService,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const organizations = await service.fetchUserOrganizations();
|
||||
const active = organizations.find((org) => org.active) ?? null;
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
organizationId: active?.organizationId,
|
||||
organizationName: active?.name,
|
||||
memberId: active?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Ignore; see above.
|
||||
}
|
||||
}
|
||||
|
||||
export function getAcpOrgSubscriptionMessage(): string {
|
||||
return [
|
||||
"Organization accounts cannot use ClinePass subscriptions.",
|
||||
'Switch the "Account" session option to Personal to keep using ClinePass,',
|
||||
'or switch the "Provider" option to Cline to bill your organization.',
|
||||
].join(" ");
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
import {
|
||||
replaySessionHistory,
|
||||
translateHistoricalMessage,
|
||||
} from "./session-load";
|
||||
|
||||
describe("translateHistoricalMessage", () => {
|
||||
it("maps string content to a message chunk for the right role", () => {
|
||||
expect(translateHistoricalMessage({ role: "user", content: "hi" })).toEqual(
|
||||
[
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "hi" },
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
translateHistoricalMessage({ role: "assistant", content: "hello" }),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips the <user_input> wrapper from replayed user text", () => {
|
||||
// Persisted user messages keep their runtime-generated wrapper. Replaying
|
||||
// it verbatim leaked markup to the client, which rendered the unknown
|
||||
// element as bare text (a one-word prompt showed up as just its content
|
||||
// with the wrapper swallowed).
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: '<user_input mode="act">s</user_input>',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "s" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "lets do it" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips mode notices and formats slash commands for display", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content:
|
||||
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "are you okay?" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content:
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect rpc startup</user_command>',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "/team inspect rpc startup" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not replay the synthetic act-mode continuation prompt", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves assistant text untouched", () => {
|
||||
// Only user text carries the wrapper; agent output must replay verbatim.
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: 'Use <user_input mode="act"> to wrap prompts.',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: 'Use <user_input mode="act"> to wrap prompts.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips empty text and unknown blocks", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "redacted_thinking", data: "xxx" },
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps thinking blocks to agent_thought_chunk", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "pondering" }],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "pondering" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps tool_use to a pending tool_call", () => {
|
||||
const updates = translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call-1",
|
||||
name: "read_files",
|
||||
input: { file_paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-1",
|
||||
kind: "read",
|
||||
status: "pending",
|
||||
rawInput: { file_paths: ["a.ts"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps tool_result to a tool_call_update with flattened output", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call-1",
|
||||
name: "read_files",
|
||||
content: [
|
||||
{ type: "text", text: "line one" },
|
||||
{ type: "image", data: "abc", mediaType: "image/png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-1",
|
||||
status: "completed",
|
||||
rawOutput: "line one\n[image]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks errored tool results as failed", () => {
|
||||
const [update] = translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call-2",
|
||||
name: "run_commands",
|
||||
content: "boom",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-2",
|
||||
status: "failed",
|
||||
rawOutput: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps image blocks to image content chunks", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [{ type: "image", data: "abc", mediaType: "image/png" }],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "image", data: "abc", mimeType: "image/png" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaySessionHistory", () => {
|
||||
it("sends one awaited notification per update, in order", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const conn = {
|
||||
sessionUpdate: vi.fn(async (notification: unknown) => {
|
||||
sent.push(notification);
|
||||
}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
await replaySessionHistory(conn, "sess-1", [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionId: "sess-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "question" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "sess-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "answer" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
import { buildToolTitle, mapToolKind } from "./tool-utils";
|
||||
|
||||
/**
|
||||
* The act-mode continuation prompt is runtime-generated, not typed by the
|
||||
* user, so it must not replay as a user turn. Mirrors the TUI transcript
|
||||
* hydration filter in tui/utils/hydrate-messages.ts.
|
||||
*/
|
||||
function isSyntheticUserText(text: string): boolean {
|
||||
return text === ACT_MODE_CONTINUATION_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a persisted conversation to the client as session/update
|
||||
* notifications. Used by `session/load` — the ACP spec requires the entire
|
||||
* conversation to be replayed before the load request resolves, so each
|
||||
* notification is awaited.
|
||||
*/
|
||||
export async function replaySessionHistory(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
for (const update of translateHistoricalMessage(message)) {
|
||||
await conn.sessionUpdate({ sessionId, update });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
|
||||
const blocks: ContentBlock[] =
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text", text: message.content }]
|
||||
: message.content;
|
||||
|
||||
const updates: SessionUpdate[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
if (!block.text) break;
|
||||
if (message.role !== "user") {
|
||||
updates.push({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: block.text },
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Display boundary: persisted user text keeps its runtime-generated
|
||||
// <user_input mode="..."> wrapper and <mode_notice> elements (they are
|
||||
// the durable record of the mode each turn was sent in). Replaying them
|
||||
// verbatim leaks markup to the client, which renders the unknown
|
||||
// element as bare text — so `s` shows up as `s` with the wrapper
|
||||
// swallowed. Strip them the same way every other surface does.
|
||||
const text = formatDisplayUserInput(block.text);
|
||||
if (!text || isSyntheticUserText(text)) break;
|
||||
updates.push({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "thinking": {
|
||||
if (!block.thinking) break;
|
||||
updates.push({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: block.thinking },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
const content = {
|
||||
type: "image" as const,
|
||||
data: block.data,
|
||||
mimeType: block.mediaType,
|
||||
};
|
||||
updates.push(
|
||||
message.role === "user"
|
||||
? { sessionUpdate: "user_message_chunk", content }
|
||||
: { sessionUpdate: "agent_message_chunk", content },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_use": {
|
||||
updates.push({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: block.id,
|
||||
title: buildToolTitle(block.name, block.input),
|
||||
kind: mapToolKind(block.name),
|
||||
status: "pending",
|
||||
rawInput: block.input,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool_result": {
|
||||
updates.push({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: block.tool_use_id,
|
||||
status: block.is_error ? "failed" : "completed",
|
||||
rawOutput: flattenToolResultContent(block.content),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
function flattenToolResultContent(
|
||||
content: ToolResultContent["content"],
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.map((part) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return part.text;
|
||||
case "file":
|
||||
return part.content;
|
||||
default:
|
||||
return "[image]";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getErrorMessage } from "@cline/shared";
|
||||
import { buildToolTitle, mapToolKind } from "./tool-utils";
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,11 @@ function translateContentStart(
|
||||
}
|
||||
}
|
||||
|
||||
export function describeAgentError(error: unknown): string {
|
||||
const message = getErrorMessage(error).trim();
|
||||
return message || "The agent reported an unknown error.";
|
||||
}
|
||||
|
||||
function translateContentEnd(
|
||||
event: AgentEvent & { type: "content_end" },
|
||||
): SessionUpdate[] {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof-of-concept: driving the interactive TUI with tuistory
|
||||
// (https://github.com/remorses/tuistory) instead of `script` + timed printf.
|
||||
//
|
||||
// Compare with `cli.interactive.e2e.test.ts`, which pipes keystrokes through
|
||||
// the Unix `script` utility on a fixed sleep schedule and greps the raw
|
||||
// output dump. Here each test launches the CLI in a real PTY backed by a
|
||||
// Ghostty terminal emulator, waits reactively for screen content
|
||||
// (`waitForText` resolves as soon as the text renders), and asserts against
|
||||
// the emulated screen state rather than the raw byte stream.
|
||||
//
|
||||
// Run with: bun run test:e2e:tuistory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { launchTerminal, type Session } from "tuistory";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const cliRoot = path.resolve(__dirname, "..");
|
||||
const cliEntry = path.join(cliRoot, "src", "index.ts");
|
||||
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
|
||||
|
||||
const LAUNCH_TIMEOUT_MS = 30_000;
|
||||
const UI_TIMEOUT_MS = 15_000;
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const sessions: Session[] = [];
|
||||
|
||||
function createCliEnv(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): Record<string, string | undefined> {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-data-"));
|
||||
const sessionDir = mkdtempSync(
|
||||
path.join(os.tmpdir(), "cli-tuistory-sessions-"),
|
||||
);
|
||||
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-teams-"));
|
||||
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
|
||||
|
||||
return {
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
CLINE_TELEMETRY_DISABLED: "1",
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
// Without this, the ClinePass promo dialog renders over the chat view.
|
||||
// The stream-grepping interactive suite doesn't notice the overlay, but
|
||||
// tuistory's screen snapshot reflects what the user actually sees.
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
// The parent vitest process sets CI/VITEST; clear them so the spawned
|
||||
// CLI renders as a real interactive terminal.
|
||||
CI: undefined,
|
||||
VITEST: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function launchCli(
|
||||
extraArgs: string[] = [],
|
||||
env: Record<string, string | undefined> = createCliEnv(),
|
||||
): Promise<Session> {
|
||||
const session = await launchTerminal({
|
||||
command: bunExec,
|
||||
args: [
|
||||
cliEntry,
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
...extraArgs,
|
||||
],
|
||||
cwd: cliRoot,
|
||||
env,
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
// The CLI compiles a large TS graph on cold start; don't gate launch
|
||||
// on the default 5s first-data timeout.
|
||||
waitForDataTimeout: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Wait for the chat view to be fully rendered. */
|
||||
async function waitForChatView(session: Session): Promise<void> {
|
||||
await session.waitForText("What can I do for you?", {
|
||||
timeout: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
describe("cli tuistory e2e", () => {
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) {
|
||||
try {
|
||||
// Double Ctrl+C exits the TUI cleanly (first press shows the
|
||||
// "press again to exit" hint) before the PTY is torn down.
|
||||
await session.press(["ctrl", "c"]);
|
||||
await session.press(["ctrl", "c"]);
|
||||
await session.waitIdle({ timeout: 3_000 });
|
||||
} catch {
|
||||
// Session may already be dead; close() below still cleans up.
|
||||
}
|
||||
session.close();
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the interactive chat view on launch", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
|
||||
const screen = await session.text({ trimEnd: true });
|
||||
expect(screen).toContain("What can I do for you?");
|
||||
expect(screen).toContain("○ Plan ● Act (Tab)");
|
||||
expect(screen).toContain("Auto-approve all enabled (Shift+Tab)");
|
||||
});
|
||||
|
||||
it("toggles plan/act mode with Tab", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
expect(await session.text()).toContain("○ Plan ● Act (Tab)");
|
||||
|
||||
await session.press("tab");
|
||||
// Reactive wait: resolves as soon as the toggled indicator renders.
|
||||
await session.waitForText("● Plan ○ Act (Tab)", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Unlike stream-grepping, the emulated screen reflects current state:
|
||||
// the old indicator is gone, not just buried in scrollback.
|
||||
const screen = await session.text();
|
||||
expect(screen).toContain("● Plan ○ Act (Tab)");
|
||||
expect(screen).not.toContain("○ Plan ● Act (Tab)");
|
||||
});
|
||||
|
||||
it("toggles auto-approve-all with Shift+Tab", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
expect(await session.text()).toContain(
|
||||
"Auto-approve all enabled (Shift+Tab)",
|
||||
);
|
||||
|
||||
await session.press(["shift", "tab"]);
|
||||
await session.waitForText("Auto-approve all disabled (Shift+Tab)", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const screen = await session.text();
|
||||
expect(screen).not.toContain("Auto-approve all enabled (Shift+Tab)");
|
||||
});
|
||||
|
||||
it("opens /settings, navigates tabs, and closes with Escape", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
|
||||
await session.type("/settings");
|
||||
// Slash menu completion for the settings command.
|
||||
await session.waitForText("Modify agent configuration", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
// A single Enter accepts the highlighted completion and submits it.
|
||||
// (The `script`-based suite pressed Enter twice with 250ms sleeps; with
|
||||
// reactive key delivery the second Enter would leak into the settings
|
||||
// view and activate the focused row.)
|
||||
await session.press("enter");
|
||||
await session.waitForText("←/→ switch tabs", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const settingsScreen = await session.text();
|
||||
expect(settingsScreen).toContain("Settings");
|
||||
expect(settingsScreen).toContain("▸ Provider");
|
||||
|
||||
// Switch from the General tab to the MCP tab; the body swaps from the
|
||||
// provider/model rows to MCP content.
|
||||
await session.press("right");
|
||||
await session.text({
|
||||
waitFor: (text) => !text.includes("Compaction"),
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await session.press("escape");
|
||||
await session.waitForText("Use / for slash commands", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
expect(await session.text()).not.toContain("←/→ switch tabs");
|
||||
});
|
||||
|
||||
it("launches config view directly with `cline config`", async () => {
|
||||
const session = await launchCli(["config"]);
|
||||
await session.waitForText("←/→ switch tabs", {
|
||||
timeout: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
const screen = await session.text();
|
||||
expect(screen).toContain("Settings");
|
||||
expect(screen).toContain("▸ Provider");
|
||||
});
|
||||
|
||||
it("dismisses the ClinePass promo with any key and marks it as shown", async () => {
|
||||
// Re-enable the promo dialog that the shared env suppresses.
|
||||
const env = createCliEnv({ CLINE_DISABLE_CLINE_PASS_NOTICE: undefined });
|
||||
const dataDir = env.CLINE_DATA_DIR as string;
|
||||
const session = await launchCli([], env);
|
||||
|
||||
await session.waitForText("Try ClinePass", { timeout: LAUNCH_TIMEOUT_MS });
|
||||
await session.waitForText("Press Enter to open, any other key to close", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Any key other than Enter dismisses the dialog (Esc is unreliable in
|
||||
// some terminals, notably on Windows).
|
||||
await session.type("x");
|
||||
await session.text({
|
||||
waitFor: (text) => !text.includes("Try ClinePass"),
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const screen = await session.text();
|
||||
expect(screen).toContain("What can I do for you?");
|
||||
expect(screen).not.toContain("Open ClinePass");
|
||||
|
||||
// The "shown" marker is persisted once the dialog is dismissed so the
|
||||
// promo doesn't reappear on the next launch.
|
||||
const markerPath = path.join(dataDir, "settings", "cli-notices.json");
|
||||
await session.waitIdle({ timeout: UI_TIMEOUT_MS });
|
||||
expect(existsSync(markerPath)).toBe(true);
|
||||
expect(readFileSync(markerPath, "utf8")).toContain(
|
||||
'"cline-cli-cline-pass-intro": true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import open from "../utils/open";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import open from "../utils/open";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
resolveClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import open from "../utils/open";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
|
||||
mockEnsureCliHubServer: vi.fn(),
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: mockSpawn,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
ensureCliHubServerAfterUpdate,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
@@ -21,6 +42,14 @@ const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createChildProcessThatCloses(exitCode: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
@@ -236,6 +265,70 @@ describe("hub restart owner selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-update hub launch", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureCliHubServer.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
});
|
||||
|
||||
it("uses the freshly installed wrapper instead of the current executable", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
|
||||
const env = {
|
||||
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
CLINE_NO_AUTO_UPDATE: "0",
|
||||
};
|
||||
|
||||
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
["hub", "ensure"],
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
env: {
|
||||
...env,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
await ensureCliHubServerAfterUpdate(
|
||||
"C:\\workspace\\project",
|
||||
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
|
||||
"C:\\workspace\\project",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a failure from the freshly installed CLI", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
|
||||
|
||||
await expect(
|
||||
ensureCliHubServerAfterUpdate(
|
||||
"/workspace/project",
|
||||
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
|
||||
"linux",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"freshly installed Cline failed to start the hub (exit code 1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
|
||||
@@ -237,6 +237,50 @@ async function runKanbanUpdate(
|
||||
return waitForProcessExit(updateProcess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the hub through the freshly installed CLI after a self-update.
|
||||
*
|
||||
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
|
||||
* remove that cached executable while replacing the package and then block the
|
||||
* postinstall script that recreates it. The current process keeps running from
|
||||
* the unlinked executable, but process.execPath is no longer spawnable. Going
|
||||
* back through the wrapper makes it resolve the newly installed platform
|
||||
* binary instead.
|
||||
*
|
||||
* Windows does not create the bin/.cline cache, and development builds do not
|
||||
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
|
||||
* ensure path.
|
||||
*/
|
||||
export async function ensureCliHubServerAfterUpdate(
|
||||
workspaceRoot: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (!wrapperPath || platform === "win32") {
|
||||
await ensureCliHubServer(workspaceRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(wrapperPath, ["hub", "ensure"], {
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...env,
|
||||
// The fresh CLI only exists to start the hub. Do not let it launch
|
||||
// another background update check while this update is finishing.
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return "";
|
||||
@@ -342,7 +386,7 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
|
||||
await ensureCliHubServerAfterUpdate(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
|
||||
@@ -338,6 +338,88 @@ describe("slack binding lookup", () => {
|
||||
expect(calls).toEqual(["get:T123", "token:xoxb-team-token", "work"]);
|
||||
});
|
||||
|
||||
it("strips the leading bot mention from Slack message text", () => {
|
||||
expect(
|
||||
__test__.stripSlackBotMention("@U0B8E8H3U1F hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention("<@U0B8E8H3U1F> hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention("<@U0B8E8H3U1F|cline> hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention(" @U0B8E8H3U1F: hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention(
|
||||
"@U0B8E8H3U1F @U0B8E8H3U1F hi",
|
||||
"U0B8E8H3U1F",
|
||||
),
|
||||
).toBe("hi");
|
||||
});
|
||||
|
||||
it("keeps Slack text that does not start with the bot mention", () => {
|
||||
expect(
|
||||
__test__.stripSlackBotMention("hi @U0B8E8H3U1F", "U0B8E8H3U1F"),
|
||||
).toBe("hi @U0B8E8H3U1F");
|
||||
expect(__test__.stripSlackBotMention("@U999999 hi", "U0B8E8H3U1F")).toBe(
|
||||
"@U999999 hi",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("@cline hi", "U0B8E8H3U1F")).toBe(
|
||||
"@cline hi",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F hi", undefined)).toBe(
|
||||
"@U0B8E8H3U1F hi",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps mentions of other Slack users whose id starts with the bot id", () => {
|
||||
expect(__test__.stripSlackBotMention("@U1234 help", "U123")).toBe(
|
||||
"@U1234 help",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("@U123 hi", "U123")).toBe("hi");
|
||||
expect(__test__.stripSlackBotMention("<@U1234> help", "U123")).toBe(
|
||||
"<@U1234> help",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("<@U1234|other> help", "U123")).toBe(
|
||||
"<@U1234|other> help",
|
||||
);
|
||||
expect(
|
||||
__test__.stripSlackBotMention("@U0B8E8H3U1FX hi", "U0B8E8H3U1F"),
|
||||
).toBe("@U0B8E8H3U1FX hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention(
|
||||
"@U0B8E8H3U1F @U0B8E8H3U1FX hi",
|
||||
"U0B8E8H3U1F",
|
||||
),
|
||||
).toBe("@U0B8E8H3U1FX hi");
|
||||
});
|
||||
|
||||
it("keeps a bare Slack bot mention so the turn is not dropped", () => {
|
||||
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F", "U0B8E8H3U1F")).toBe(
|
||||
"@U0B8E8H3U1F",
|
||||
);
|
||||
expect(
|
||||
__test__.stripSlackBotMention("<@U0B8E8H3U1F> ", "U0B8E8H3U1F"),
|
||||
).toBe("<@U0B8E8H3U1F> ");
|
||||
});
|
||||
|
||||
it("resolves the Slack bot user id from the adapter or event authorizations", () => {
|
||||
expect(__test__.resolveSlackBotUserId({ botUserId: "U0B8E8H3U1F" })).toBe(
|
||||
"U0B8E8H3U1F",
|
||||
);
|
||||
expect(
|
||||
__test__.resolveSlackBotUserId(
|
||||
{ botUserId: undefined },
|
||||
{ authorizations: [{ user_id: "U0B8E8H3U1F" }] },
|
||||
),
|
||||
).toBe("U0B8E8H3U1F");
|
||||
expect(
|
||||
__test__.resolveSlackBotUserId({ botUserId: undefined }, { text: "hi" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects Slack invalid_thread_ts errors", () => {
|
||||
expect(
|
||||
__test__.isSlackInvalidThreadTsError(
|
||||
|
||||
@@ -206,6 +206,60 @@ function extractSlackChannelFromId(id: string): string | undefined {
|
||||
return parts[0] === "slack" ? readString(parts[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack delivers `@cline hi` as `<@U0B8E8H3U1F> hi`, and the chat SDK
|
||||
* deliberately leaves the bot's own mention unresolved (so mention detection
|
||||
* keeps working), flattening it to `@U0B8E8H3U1F hi`. Strip that leading
|
||||
* self-mention so the agent receives `hi`.
|
||||
*
|
||||
* Only leading mentions of the bot itself are removed; mentions of other users
|
||||
* (already resolved to `@display-name`) and inline mentions are preserved so
|
||||
* the agent still sees who was addressed. A bare mention with no other content
|
||||
* is left untouched so the turn still reaches the agent instead of being
|
||||
* dropped as empty input.
|
||||
*/
|
||||
function stripSlackBotMention(
|
||||
text: string,
|
||||
botUserId: string | undefined,
|
||||
): string {
|
||||
const botId = botUserId?.trim();
|
||||
if (!botId || !text) {
|
||||
return text;
|
||||
}
|
||||
const escapedBotId = botId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Matches `<@U123>`, `<@U123|name>` and the SDK-flattened `@U123` form,
|
||||
// repeated when a user mentions the bot more than once up front.
|
||||
//
|
||||
// The angle-bracket forms are delimited by `>`, but the flattened form has no
|
||||
// closing delimiter, so it needs an explicit boundary. Without one, `@U123`
|
||||
// also matches the start of a longer id belonging to someone else, turning
|
||||
// `@U1234 help` into `4 help`. Slack ids are uppercase alphanumeric, so a
|
||||
// complete mention is one that is not followed by another id character.
|
||||
// `\b` cannot express this: ids end in word characters, so `@U123\b` still
|
||||
// matches inside `@U1234`.
|
||||
const leadingMention = new RegExp(
|
||||
`^(?:\\s*(?:<@${escapedBotId}(?:\\|[^<>]*)?>|@${escapedBotId}(?![A-Za-z0-9]))[\\s,:]*)+`,
|
||||
);
|
||||
const stripped = text.replace(leadingMention, "");
|
||||
return stripped.trim() ? stripped.trimStart() : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter exposes the authenticated bot user id (request-scoped in
|
||||
* multi-workspace mode). When it is not yet known, fall back to the id Slack
|
||||
* reports as the authorized app user on the event envelope.
|
||||
*/
|
||||
function resolveSlackBotUserId(
|
||||
slack: Pick<SlackAdapter, "botUserId">,
|
||||
rawMessage?: unknown,
|
||||
): string | undefined {
|
||||
const raw = asRecord(rawMessage);
|
||||
return (
|
||||
readString(slack.botUserId) ??
|
||||
readString(firstRecord(raw?.authorizations)?.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSlackChannelMentionThread(
|
||||
thread: Thread<SlackThreadState>,
|
||||
message: Message,
|
||||
@@ -967,10 +1021,14 @@ class SlackConnector extends ConnectorBase<
|
||||
rawMessage: message.raw,
|
||||
errorLabel: "Slack",
|
||||
});
|
||||
const text = stripSlackBotMention(
|
||||
message.text,
|
||||
resolveSlackBotUserId(slack, message.raw),
|
||||
);
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread: mentionThread,
|
||||
text: message.text,
|
||||
text,
|
||||
client,
|
||||
clientId,
|
||||
pendingApprovals,
|
||||
@@ -979,7 +1037,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(mentionThread, message.text);
|
||||
await handleTurn(mentionThread, text);
|
||||
});
|
||||
|
||||
bot.onSubscribedMessage(async (thread, message) => {
|
||||
@@ -990,10 +1048,14 @@ class SlackConnector extends ConnectorBase<
|
||||
rawMessage: message.raw,
|
||||
errorLabel: "Slack",
|
||||
});
|
||||
const text = stripSlackBotMention(
|
||||
message.text,
|
||||
resolveSlackBotUserId(slack, message.raw),
|
||||
);
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread,
|
||||
text: message.text,
|
||||
text,
|
||||
client,
|
||||
clientId,
|
||||
pendingApprovals,
|
||||
@@ -1002,7 +1064,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(thread, message.text);
|
||||
await handleTurn(thread, text);
|
||||
});
|
||||
|
||||
bot.onSlashCommand(async (event) => {
|
||||
@@ -1215,7 +1277,9 @@ export const __test__ = {
|
||||
buildSlackParticipantKey,
|
||||
resolveSlackParticipant,
|
||||
normalizeSlackMessageEventChannelType,
|
||||
resolveSlackBotUserId,
|
||||
resolveSlackChannelMentionThread,
|
||||
stripSlackBotMention,
|
||||
withSlackTeamBotToken,
|
||||
isSlackInvalidThreadTsError,
|
||||
findBindingForThread: (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMigrationNoticeKeyAction } from "./notice-dialog";
|
||||
|
||||
vi.mock("@opentui-ui/dialog/react", () => ({
|
||||
useDialogKeyboard: () => undefined,
|
||||
}));
|
||||
|
||||
describe("resolveMigrationNoticeKeyAction", () => {
|
||||
it("opens the subscription page on Enter", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "return" })).toBe("open");
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "enter" })).toBe("open");
|
||||
});
|
||||
|
||||
it("dismisses on Escape", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "escape" })).toBe("dismiss");
|
||||
});
|
||||
|
||||
it("dismisses on any other unmodified key so users are never stuck behind the promo", () => {
|
||||
for (const name of ["q", "x", "space", "tab", "backspace", "up"]) {
|
||||
expect(resolveMigrationNoticeKeyAction({ name })).toBe("dismiss");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores modifier-held keys so holding Cmd/Ctrl to click the link never dismisses", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "c", ctrl: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "x", meta: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "x", super: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
// A bare modifier press (empty name) is ignored, not a dismiss.
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "" })).toBe("ignore");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,34 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
} from "../tui/utils/dialog-keys";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import open from "../utils/open";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
/**
|
||||
* Enter opens the subscription page; any other (unmodified) key dismisses the
|
||||
* dialog; modifier-held keys are ignored.
|
||||
*
|
||||
* The dialog used to be dismissible only with Esc, but Esc is the least
|
||||
* reliable key across terminals (it arrives as a bare `\x1b` that needs
|
||||
* timeout disambiguation, and Windows console input layers are known to
|
||||
* swallow it), which left users stuck behind the promo with no way out.
|
||||
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
|
||||
* subscription link never dismisses the dialog mid-click.
|
||||
*/
|
||||
export function resolveMigrationNoticeKeyAction(
|
||||
key: DialogDismissKey,
|
||||
): "open" | "dismiss" | "ignore" {
|
||||
if (!isAnyKeyDismiss(key)) return "ignore";
|
||||
return key.name === "return" || key.name === "enter" ? "open" : "dismiss";
|
||||
}
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
notice: CliMigrationNotice;
|
||||
@@ -30,13 +52,13 @@ export function MigrationNoticeContent(
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
const action = resolveMigrationNoticeKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "open") {
|
||||
openSubscriptionPage();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
resolve(true);
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
@@ -48,7 +70,7 @@ export function MigrationNoticeContent(
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
<text selectable>Try it now with a limited-time promo for $4.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
@@ -61,7 +83,9 @@ export function MigrationNoticeContent(
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
<text fg={palette.muted}>
|
||||
Press Enter to open, any other key to close
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
|
||||
const sessionManagerMocks = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
@@ -39,10 +40,8 @@ const sessionEventsMocks = vi.hoisted(() => ({
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { CheckpointEntry } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
|
||||
|
||||
function userPrompt(text: string): Message {
|
||||
return { role: "user", content: [{ type: "text", text }] } as Message;
|
||||
}
|
||||
|
||||
function toolResult(): Message {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "t", content: "ok" }],
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
function assistant(text: string): Message {
|
||||
return { role: "assistant", content: [{ type: "text", text }] } as Message;
|
||||
}
|
||||
|
||||
const history: CheckpointEntry[] = [
|
||||
{ ref: "ref1", createdAt: 1, runCount: 1, kind: "commit" },
|
||||
{ ref: "ref2", createdAt: 2, runCount: 2, kind: "stash" },
|
||||
];
|
||||
|
||||
describe("buildCheckpointPickerItems", () => {
|
||||
it("numbers runs span-aware so tool-result messages don't inflate the count", () => {
|
||||
// A transcript with tool-result messages (role "user") between prompts,
|
||||
// exactly the shape that made the old raw-role counting emit run 5 for
|
||||
// the second prompt and abort restore.
|
||||
const messages: Message[] = [
|
||||
userPrompt("first request"),
|
||||
assistant("working"),
|
||||
toolResult(),
|
||||
assistant("working more"),
|
||||
toolResult(),
|
||||
userPrompt("second request"),
|
||||
assistant("done"),
|
||||
toolResult(),
|
||||
];
|
||||
|
||||
const items = buildCheckpointPickerItems(messages, history);
|
||||
|
||||
expect(items.map((item) => item.runCount)).toEqual([1, 2]);
|
||||
expect(items.map((item) => item.text)).toEqual([
|
||||
"first request",
|
||||
"second request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps each real user turn to the nearest checkpoint at or before it", () => {
|
||||
const messages: Message[] = [
|
||||
userPrompt("first request"),
|
||||
toolResult(),
|
||||
userPrompt("second request"),
|
||||
];
|
||||
|
||||
const items = buildCheckpointPickerItems(messages, [
|
||||
{ ref: "only", createdAt: 1, runCount: 1, kind: "commit" },
|
||||
]);
|
||||
|
||||
// Run 2 has no exact checkpoint; it falls back to the run-1 entry.
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ runCount: 1, text: "first request" }),
|
||||
expect.objectContaining({ runCount: 2, text: "second request" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts a compaction summary as spanning the runs it folded", () => {
|
||||
const compaction = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Compacted context" }],
|
||||
metadata: { kind: "compaction", userRunSpan: 2 },
|
||||
} as unknown as Message;
|
||||
const messages: Message[] = [compaction, userPrompt("third request")];
|
||||
|
||||
const items = buildCheckpointPickerItems(messages, [
|
||||
{ ref: "r3", createdAt: 3, runCount: 3, kind: "stash" },
|
||||
]);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ runCount: 3, text: "third request" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CheckpointEntry } from "@cline/core";
|
||||
import { getUserRunSpan } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { formatDisplayUserInput, truncateStr } from "@cline/shared";
|
||||
import type { CheckpointPickerItem } from "./components/dialogs/checkpoint-picker";
|
||||
|
||||
/** Highest checkpoint recorded at or before `runCount`. */
|
||||
function checkpointForRun(
|
||||
checkpointHistory: readonly CheckpointEntry[],
|
||||
runCount: number,
|
||||
): CheckpointEntry | undefined {
|
||||
return checkpointHistory.reduce<CheckpointEntry | undefined>(
|
||||
(best, checkpoint) => {
|
||||
if (checkpoint.runCount > runCount) {
|
||||
return best;
|
||||
}
|
||||
if (!best || checkpoint.runCount > best.runCount) {
|
||||
return checkpoint;
|
||||
}
|
||||
return best;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function extractText(content: Message["content"]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { type: "text"; text: string } =>
|
||||
typeof b === "object" &&
|
||||
b !== null &&
|
||||
"type" in b &&
|
||||
(b as { type?: unknown }).type === "text" &&
|
||||
"text" in b &&
|
||||
typeof (b as { text?: unknown }).text === "string",
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `/undo` checkpoint picker rows from the raw conversation and the
|
||||
* recorded checkpoint history.
|
||||
*
|
||||
* The run count MUST advance with `getUserRunSpan`, exactly as the core does
|
||||
* when it numbers checkpoints and later resolves them. Tool-result messages
|
||||
* carry role "user" but contribute 0, and a compaction summary spans the turns
|
||||
* it folded. Counting raw "user" messages overcounts, so the picker would hand
|
||||
* restore a run number the core cannot map — surfacing as
|
||||
* "Could not find user message for run N" and aborting the restore.
|
||||
*/
|
||||
export function buildCheckpointPickerItems(
|
||||
rawMessages: readonly Message[],
|
||||
checkpointHistory: readonly CheckpointEntry[],
|
||||
): CheckpointPickerItem[] {
|
||||
const items: CheckpointPickerItem[] = [];
|
||||
let userRunCount = 0;
|
||||
for (const msg of rawMessages) {
|
||||
const span = getUserRunSpan(msg);
|
||||
if (span < 1) {
|
||||
continue;
|
||||
}
|
||||
userRunCount += span;
|
||||
const checkpoint = checkpointForRun(checkpointHistory, userRunCount);
|
||||
if (!checkpoint) {
|
||||
continue;
|
||||
}
|
||||
const text = extractText(msg.content);
|
||||
const preview = truncateStr(
|
||||
formatDisplayUserInput(text).replace(/\s+/g, " "),
|
||||
60,
|
||||
);
|
||||
if (!preview) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
runCount: userRunCount,
|
||||
text: preview,
|
||||
fullText: text,
|
||||
createdAt: checkpoint.createdAt,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
} from "../../utils/dialog-keys";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
@@ -45,6 +49,26 @@ export function saveManualProviderApiKey(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key handling for the OAuth waiting screens: `K` switches to manual API key
|
||||
* entry when that fallback is available; any other (unmodified) key cancels
|
||||
* the pending auth attempt; modifier-held keys are ignored.
|
||||
*
|
||||
* Like the ClinePass promo dialog, this screen must never depend on Esc
|
||||
* alone: it is non-interactive, it may be waiting on a browser flow that
|
||||
* never completes, and Esc is the least reliably delivered key across
|
||||
* terminals (notably on Windows, where console input layers can swallow it).
|
||||
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
|
||||
* auth/verification link never cancels the flow mid-click.
|
||||
*/
|
||||
export function resolveOAuthWaitKeyAction(
|
||||
key: DialogDismissKey,
|
||||
allowApiKeyFallback: boolean | undefined,
|
||||
): "use_api_key" | "cancel" | "ignore" {
|
||||
if (!isAnyKeyDismiss(key)) return "ignore";
|
||||
return allowApiKeyFallback && key.name === "k" ? "use_api_key" : "cancel";
|
||||
}
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
@@ -53,6 +77,8 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
if (CLI_PROMO_CODE) {
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -9,22 +9,61 @@ import {
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
resolveOAuthWaitKeyAction,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("resolveOAuthWaitKeyAction", () => {
|
||||
it("switches to manual API key entry on K when the fallback is available", () => {
|
||||
expect(resolveOAuthWaitKeyAction({ name: "k" }, true)).toBe("use_api_key");
|
||||
});
|
||||
|
||||
it("cancels on K when the fallback is not available", () => {
|
||||
expect(resolveOAuthWaitKeyAction({ name: "k" }, false)).toBe("cancel");
|
||||
});
|
||||
|
||||
it("cancels on any other unmodified key so users are never stuck waiting on a browser flow", () => {
|
||||
for (const name of ["escape", "q", "return", "space", "up", "x"]) {
|
||||
expect(resolveOAuthWaitKeyAction({ name }, true)).toBe("cancel");
|
||||
expect(resolveOAuthWaitKeyAction({ name }, false)).toBe("cancel");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores modifier-held keys so holding Cmd/Ctrl to click the auth link never cancels", () => {
|
||||
expect(resolveOAuthWaitKeyAction({ name: "k", ctrl: true }, true)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveOAuthWaitKeyAction({ name: "c", ctrl: true }, false)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveOAuthWaitKeyAction({ name: "x", meta: true }, true)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveOAuthWaitKeyAction({ name: "x", super: true }, false)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
// A bare modifier press (empty name) is ignored, not a cancel.
|
||||
expect(resolveOAuthWaitKeyAction({ name: "" }, true)).toBe("ignore");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl(undefined).startsWith(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
buildClinePassSubscriptionPageUrl(
|
||||
"https://staging-app.cline.bot",
|
||||
).startsWith(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "../searchable-list";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
resolveOAuthWaitKeyAction,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
@@ -877,21 +878,20 @@ export function OAuthLoginContent(
|
||||
}, []);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
cancelAuthAttempt();
|
||||
dismiss();
|
||||
const action = resolveOAuthWaitKeyAction(key, allowApiKeyFallback);
|
||||
if (action === "ignore") return;
|
||||
cancelAuthAttempt();
|
||||
if (action === "use_api_key") {
|
||||
resolve("use_api_key");
|
||||
return;
|
||||
}
|
||||
if (key.name === "k" && allowApiKeyFallback) {
|
||||
cancelAuthAttempt();
|
||||
resolve("use_api_key");
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
const cancelHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, any other key to cancel"
|
||||
: "Press any key to cancel";
|
||||
const cancelHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
@@ -919,8 +919,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
<text fg={cancelHintColor}>
|
||||
<em>{cancelHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -942,8 +942,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
<text fg={cancelHintColor}>
|
||||
<em>{cancelHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -93,14 +93,3 @@ export function freeTierDescriptionFor(
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
@@ -86,13 +85,4 @@ describe("cline model picker entries", () => {
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
export {
|
||||
@@ -23,7 +22,6 @@ export {
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
@@ -32,24 +30,6 @@ function tagColor(tag: string): string {
|
||||
return palette.act;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -75,10 +55,9 @@ export function ClineModelPicker(props: {
|
||||
entries: ClineModelPickerEntry[];
|
||||
selected: number;
|
||||
loading?: boolean;
|
||||
knownModels?: Record<string, unknown>;
|
||||
currentModelId?: string;
|
||||
}) {
|
||||
const { entries, selected, loading, knownModels, currentModelId } = props;
|
||||
const { entries, selected, loading, currentModelId } = props;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -122,7 +101,8 @@ export function ClineModelPicker(props: {
|
||||
}
|
||||
|
||||
const tags = entry.model.tags;
|
||||
const name = resolveDisplayName(entry.model.id, knownModels);
|
||||
// Names arrive display-ready from fetchClineRecommendedModels
|
||||
const name = entry.model.name || entry.model.id;
|
||||
const isCurrent = currentModelId === entry.model.id;
|
||||
rows.push(
|
||||
<box
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
@@ -25,29 +24,10 @@ function tagColor(tag: string): string {
|
||||
return palette.act;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
props: ChoiceContext<string> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
entries: ClineModelPickerEntry[];
|
||||
},
|
||||
) {
|
||||
@@ -57,7 +37,6 @@ export function ClineModelSelectorContent(
|
||||
dialogId,
|
||||
currentModel,
|
||||
currentProviderName,
|
||||
knownModels,
|
||||
entries,
|
||||
} = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
@@ -95,7 +74,8 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: entry.model.id,
|
||||
kind: "model",
|
||||
label: resolveDisplayName(entry.model.id, knownModels),
|
||||
// Names arrive display-ready from fetchClineRecommendedModels
|
||||
label: entry.model.name || entry.model.id,
|
||||
tags: entry.model.tags,
|
||||
isCurrent: currentModel === entry.model.id,
|
||||
entryIndex: i,
|
||||
@@ -112,7 +92,7 @@ export function ClineModelSelectorContent(
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [entries, knownModels, currentModel]);
|
||||
}, [entries, currentModel]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -238,7 +218,6 @@ export function ClineModelSelectorDialogContent(
|
||||
props: ChoiceContext<string> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
loadEntries: () => Promise<ClineModelPickerEntry[]>;
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback } from "react";
|
||||
import open from "../../utils/open";
|
||||
import type { ClineAccountSnapshot } from "../cline-account";
|
||||
import {
|
||||
type AccountDialogAction,
|
||||
|
||||
@@ -444,7 +444,6 @@ export function useModelSelector(opts: {
|
||||
{...ctx}
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildFeaturedModelEntries(
|
||||
featuredProviderId,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getCurrentContextSize, summarizeUsageFromMessages } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { formatDisplayUserInput, truncateStr } from "@cline/shared";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import type { KeyEvent } from "@opentui/core";
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
@@ -14,6 +13,7 @@ import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanba
|
||||
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
|
||||
import type { TranscriptScrollHandle } from "./components/chat-message-list";
|
||||
import {
|
||||
CheckpointConfirmContent,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "./components/dialogs/checkpoint-confirm";
|
||||
import {
|
||||
CheckpointPickerContent,
|
||||
type CheckpointPickerItem,
|
||||
type CheckpointPickerResult,
|
||||
} from "./components/dialogs/checkpoint-picker";
|
||||
import {
|
||||
@@ -303,61 +302,7 @@ function App(props: TuiProps) {
|
||||
showToast("No checkpoints available", "info");
|
||||
return;
|
||||
}
|
||||
const checkpointForRun = (runCount: number) =>
|
||||
checkpointHistory.reduce<
|
||||
(typeof checkpointHistory)[number] | undefined
|
||||
>((best, checkpoint) => {
|
||||
if (checkpoint.runCount > runCount) {
|
||||
return best;
|
||||
}
|
||||
if (!best || checkpoint.runCount > best.runCount) {
|
||||
return checkpoint;
|
||||
}
|
||||
return best;
|
||||
}, undefined);
|
||||
const items: CheckpointPickerItem[] = [];
|
||||
let userRunCount = 0;
|
||||
for (const msg of rawMessages as Array<
|
||||
Message & { metadata?: Record<string, unknown> }
|
||||
>) {
|
||||
if (msg.role !== "user") continue;
|
||||
const metadata =
|
||||
"metadata" in msg && msg.metadata && typeof msg.metadata === "object"
|
||||
? msg.metadata
|
||||
: undefined;
|
||||
if (metadata?.kind === "recovery_notice") continue;
|
||||
userRunCount += 1;
|
||||
const checkpoint = checkpointForRun(userRunCount);
|
||||
if (!checkpoint) continue;
|
||||
const text =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content
|
||||
.filter(
|
||||
(b): b is { type: "text"; text: string } =>
|
||||
typeof b === "object" &&
|
||||
b !== null &&
|
||||
"type" in b &&
|
||||
b.type === "text" &&
|
||||
"text" in b &&
|
||||
typeof b.text === "string",
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join(" ")
|
||||
: "";
|
||||
const preview = truncateStr(
|
||||
formatDisplayUserInput(text).replace(/\s+/g, " "),
|
||||
60,
|
||||
);
|
||||
if (!preview) continue;
|
||||
items.push({
|
||||
runCount: userRunCount,
|
||||
text: preview,
|
||||
fullText: text,
|
||||
createdAt: checkpoint.createdAt,
|
||||
});
|
||||
}
|
||||
const items = buildCheckpointPickerItems(rawMessages, checkpointHistory);
|
||||
if (items.length === 0) {
|
||||
showToast("No checkpoints to restore", "info");
|
||||
return;
|
||||
@@ -416,7 +361,11 @@ function App(props: TuiProps) {
|
||||
session.replaceEntries(entries);
|
||||
session.setHasSubmitted(entries.length > 0);
|
||||
setAppView(entries.length > 0 ? "chat" : "home");
|
||||
populateInputRef.current(picked.fullText);
|
||||
// Prefill the display form of the rewound message, not the raw
|
||||
// stored text: the runtime wraps outbound prompts in a
|
||||
// <user_input mode="..."> envelope, which must not leak into the
|
||||
// input box the user is about to edit and re-send.
|
||||
populateInputRef.current(formatDisplayUserInput(picked.fullText));
|
||||
showToast("Restored to checkpoint", "success");
|
||||
} catch (error) {
|
||||
const message = `Checkpoint restore failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
@@ -447,7 +396,7 @@ function App(props: TuiProps) {
|
||||
),
|
||||
});
|
||||
if (selected === SKILLS_MARKETPLACE_ACTION) {
|
||||
await import("open")
|
||||
await import("../utils/open")
|
||||
.then(({ default: open }) => open(SKILLS_MARKETPLACE_URL))
|
||||
.catch(() => {
|
||||
showToast(`Visit ${SKILLS_MARKETPLACE_URL}`, "info");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAnyKeyDismiss } from "./dialog-keys";
|
||||
|
||||
describe("isAnyKeyDismiss", () => {
|
||||
it("treats ordinary unmodified keys as a dismiss", () => {
|
||||
for (const name of ["escape", "q", "x", "return", "space", "up"]) {
|
||||
expect(isAnyKeyDismiss({ name })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats Shift- and Option-held keys as a dismiss (ordinary typed chars)", () => {
|
||||
expect(isAnyKeyDismiss({ name: "x", shift: true } as never)).toBe(true);
|
||||
expect(isAnyKeyDismiss({ name: "x", option: true } as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat link-click / shortcut modifiers as a dismiss", () => {
|
||||
expect(isAnyKeyDismiss({ name: "x", ctrl: true })).toBe(false);
|
||||
expect(isAnyKeyDismiss({ name: "x", meta: true })).toBe(false);
|
||||
expect(isAnyKeyDismiss({ name: "x", super: true })).toBe(false);
|
||||
expect(isAnyKeyDismiss({ name: "x", hyper: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores bare modifier presses with no key name", () => {
|
||||
expect(isAnyKeyDismiss({ name: "" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Minimal key shape for dialog dismissal decisions. Structurally compatible
|
||||
* with OpenTUI's `KeyEvent` so handlers can pass the event through directly.
|
||||
*/
|
||||
export interface DialogDismissKey {
|
||||
name: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
super?: boolean;
|
||||
hyper?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key event should count as an intentional "any key" dismissal.
|
||||
*
|
||||
* Dialogs that close on any key must still ignore modifier-held events:
|
||||
* users open the URLs we render by holding Cmd/Ctrl and clicking the
|
||||
* hyperlink, and that modifier keystroke must not tear the dialog out from
|
||||
* under the click. Bare modifier presses (empty name) are ignored too.
|
||||
*
|
||||
* Shift and Option are intentionally not treated as blocking — they only
|
||||
* produce ordinary typed characters, not the link-opening chord (Cmd-click
|
||||
* on macOS, Ctrl-click elsewhere).
|
||||
*/
|
||||
export function isAnyKeyDismiss(key: DialogDismissKey): boolean {
|
||||
if (!key.name) return false;
|
||||
return !(key.ctrl || key.meta || key.super || key.hyper);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ vi.mock("@cline/shared", () => ({
|
||||
getClineEnvironmentConfig: () => ({ apiBaseUrl: "https://api.example" }),
|
||||
}));
|
||||
|
||||
vi.mock("open", () => ({ default: hoisted.openMock }));
|
||||
vi.mock("../../../utils/open", () => ({ default: hoisted.openMock }));
|
||||
|
||||
vi.mock("../../../utils/feature-flags", () => ({
|
||||
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
startClineDeviceAuth,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
|
||||
import open from "../../../utils/open";
|
||||
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import open from "open";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getCliSubscriptionUrl,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
@@ -219,13 +219,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [clineKnownModels, setClineKnownModels] = useState<
|
||||
Record<string, unknown> | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// The featured picker serves both cline and cline-pass, so pool reasoning
|
||||
// support and display names from both catalogs
|
||||
// The featured picker serves both cline and cline-pass, so pool
|
||||
// reasoning support from both catalogs. Display names need no catalog
|
||||
// here: fetchClineRecommendedModels resolves them.
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
getLocalProviderModels(providerId),
|
||||
@@ -240,21 +238,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
});
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
resolveProviderConfig(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value?.knownModels) {
|
||||
Object.assign(merged, result.value.knownModels);
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged).length > 0) {
|
||||
setClineKnownModels(merged);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
@@ -838,7 +821,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
clinePassPlanFeatures,
|
||||
|
||||
@@ -447,7 +447,6 @@ export function OnboardingProviderPickerScreen(props: {
|
||||
|
||||
export function OnboardingClineModelScreen(props: {
|
||||
clineEntries: ClineModelPickerEntry[];
|
||||
clineKnownModels: Record<string, unknown> | undefined;
|
||||
clineModelSelected: number;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
@@ -472,7 +471,6 @@ export function OnboardingClineModelScreen(props: {
|
||||
entries={props.clineEntries}
|
||||
selected={props.clineModelSelected}
|
||||
loading={props.recommendedLoading}
|
||||
knownModels={props.clineKnownModels}
|
||||
/>
|
||||
|
||||
<text fg="gray" paddingX={1}>
|
||||
|
||||
@@ -112,7 +112,6 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
return (
|
||||
<OnboardingClineModelScreen
|
||||
clineEntries={state.clineEntries}
|
||||
clineKnownModels={state.clineKnownModels}
|
||||
clineModelSelected={state.clineModelSelected}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineFreeModelLimitErrorMessage,
|
||||
isClineFreePromotionEndedErrorMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
@@ -29,13 +28,7 @@ describe("cline-pass-errors", () => {
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getCliSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
|
||||
@@ -18,9 +18,16 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
if(!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
realOpenMock: vi.fn<(url: string, options?: object) => Promise<unknown>>(),
|
||||
readFileSyncMock: vi.fn<(path: string, encoding: string) => string>(),
|
||||
accessSyncMock: vi.fn<(path: string, mode?: number) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("open", () => ({ default: hoisted.realOpenMock }));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
readFileSync: hoisted.readFileSyncMock,
|
||||
accessSync: hoisted.accessSyncMock,
|
||||
constants: { X_OK: 1 },
|
||||
}));
|
||||
|
||||
import open from "./open";
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): () => void {
|
||||
const original = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: platform });
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(process, "platform", original);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const restores: Array<() => void> = [];
|
||||
let originalPath: string | undefined;
|
||||
|
||||
function usePlatform(platform: NodeJS.Platform): void {
|
||||
restores.push(setPlatform(platform));
|
||||
}
|
||||
|
||||
/** Makes exactly the given file paths "executable". */
|
||||
function mockExecutables(...paths: string[]): void {
|
||||
hoisted.accessSyncMock.mockImplementation((path: string) => {
|
||||
if (!paths.includes(path)) {
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalPath = process.env.PATH;
|
||||
process.env.PATH = "/usr/local/bin:/usr/bin";
|
||||
// Defaults: not WSL, no executables anywhere.
|
||||
hoisted.readFileSyncMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
||||
});
|
||||
mockExecutables();
|
||||
hoisted.realOpenMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
while (restores.length > 0) {
|
||||
restores.pop()?.();
|
||||
}
|
||||
hoisted.realOpenMock.mockReset();
|
||||
hoisted.readFileSyncMock.mockReset();
|
||||
hoisted.accessSyncMock.mockReset();
|
||||
});
|
||||
|
||||
describe("open wrapper (linux)", () => {
|
||||
it("passes through untouched when xdg-open is on PATH", async () => {
|
||||
usePlatform("linux");
|
||||
mockExecutables("/usr/bin/xdg-open");
|
||||
await open("https://example.com", { wait: false });
|
||||
expect(hoisted.realOpenMock).toHaveBeenCalledWith("https://example.com", {
|
||||
wait: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a packaged xdg-open script when PATH has none", async () => {
|
||||
usePlatform("linux");
|
||||
process.env.PATH = "/nowhere";
|
||||
// e.g. an xdg-open placed next to the executable.
|
||||
const packaged = join(dirname(process.execPath), "xdg-open");
|
||||
mockExecutables(packaged);
|
||||
await open("https://example.com", { wait: false });
|
||||
expect(hoisted.realOpenMock).toHaveBeenCalledWith("https://example.com", {
|
||||
wait: false,
|
||||
app: { name: packaged },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects without calling open() when no xdg-open exists anywhere — the uncatchable-crash case", async () => {
|
||||
// A missing opener binary surfaces as an async `error` event on the
|
||||
// detached, listenerless child that open() returns; under Bun it fires
|
||||
// before the microtask queue drains, so no try/catch or .catch around
|
||||
// open() can intercept it. The wrapper must reject before open() ever
|
||||
// spawns, so the call sites' existing .catch fallbacks handle it.
|
||||
usePlatform("linux");
|
||||
await expect(open("https://example.com", { wait: false })).rejects.toThrow(
|
||||
"xdg-open is not available",
|
||||
);
|
||||
expect(hoisted.realOpenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the fallback on WSL, where open uses powershell.exe", async () => {
|
||||
usePlatform("linux");
|
||||
hoisted.readFileSyncMock.mockReturnValue(
|
||||
"Linux version 5.15.90.1-microsoft-standard-WSL2",
|
||||
);
|
||||
await open("https://example.com", { wait: false });
|
||||
expect(hoisted.realOpenMock).toHaveBeenCalledWith("https://example.com", {
|
||||
wait: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never interferes when the caller specifies an app explicitly", async () => {
|
||||
usePlatform("linux");
|
||||
const options = { wait: false, app: { name: "firefox" } };
|
||||
await open("https://example.com", options);
|
||||
expect(hoisted.realOpenMock).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
options,
|
||||
);
|
||||
expect(hoisted.accessSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("open wrapper (other platforms)", () => {
|
||||
it("passes through untouched on macOS", async () => {
|
||||
usePlatform("darwin");
|
||||
await open("https://example.com", { wait: false });
|
||||
expect(hoisted.realOpenMock).toHaveBeenCalledWith("https://example.com", {
|
||||
wait: false,
|
||||
});
|
||||
expect(hoisted.accessSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes through untouched on Windows", async () => {
|
||||
usePlatform("win32");
|
||||
await open("https://example.com", { wait: false });
|
||||
expect(hoisted.realOpenMock).toHaveBeenCalledWith("https://example.com", {
|
||||
wait: false,
|
||||
});
|
||||
expect(hoisted.accessSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { accessSync, constants as fsConstants, readFileSync } from "node:fs";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import realOpen from "open";
|
||||
|
||||
/**
|
||||
* Drop-in for the `open` package that doesn't crash Linux hosts missing
|
||||
* `xdg-open`. Import this instead of `open`; everything else stays the same.
|
||||
*
|
||||
* The crash cannot be handled around the `open()` call: with
|
||||
* `{ wait: false }` it resolves to a detached, listenerless child before the
|
||||
* opener binary is known to exist, and the ENOENT arrives as an
|
||||
* asynchronous `error` event on that child. Under Bun — the runtime the
|
||||
* compiled CLI ships on — the event fires before the microtask queue
|
||||
* drains, so no `try/catch` or `.catch()` can intercept it and it escalates
|
||||
* to an uncaughtException that kills the process. So the check happens
|
||||
* before `open()` is ever called, and failure surfaces as a normal
|
||||
* rejection that the call sites' existing `.catch()` fallbacks handle.
|
||||
*/
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
accessSync(filePath, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WSL reports `process.platform === "linux"` but `open` launches URLs there
|
||||
* through `powershell.exe`, not `xdg-open`, so it must skip the check.
|
||||
*/
|
||||
function isWsl(): boolean {
|
||||
try {
|
||||
return readFileSync("/proc/version", "utf8")
|
||||
.toLowerCase()
|
||||
.includes("microsoft");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function systemHasXdgOpen(): boolean {
|
||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
if (dir && isExecutable(join(dir, "xdg-open"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `open` package ships its own copy of the `xdg-open` script (it works
|
||||
* without xdg-utils, falling back to gio/kde-open/`$BROWSER` internally),
|
||||
* but only uses the copy next to its own `index.js` — a location that does
|
||||
* not survive Bun bundling/compiling. When a real copy is on disk anyway
|
||||
* (running from source, or an `xdg-open` placed next to the binary), hand
|
||||
* it to `open` explicitly.
|
||||
*/
|
||||
function packagedXdgOpenScript(): string | undefined {
|
||||
const candidates: string[] = [];
|
||||
try {
|
||||
candidates.push(
|
||||
join(dirname(fileURLToPath(import.meta.resolve("open"))), "xdg-open"),
|
||||
);
|
||||
} catch {}
|
||||
candidates.push(join(dirname(process.execPath), "xdg-open"));
|
||||
return candidates.find((candidate) => isExecutable(candidate));
|
||||
}
|
||||
|
||||
const open: typeof realOpen = async (target, options) => {
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!options?.app &&
|
||||
!isWsl() &&
|
||||
!systemHasXdgOpen()
|
||||
) {
|
||||
const script = packagedXdgOpenScript();
|
||||
if (!script) {
|
||||
throw new Error("Cannot open browser: xdg-open is not available");
|
||||
}
|
||||
return realOpen(target, { ...options, app: { name: script } });
|
||||
}
|
||||
return realOpen(target, options);
|
||||
};
|
||||
|
||||
export default open;
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
import open from "../../utils/open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -4,7 +4,10 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.e2e.test.ts"],
|
||||
exclude: ["src/**/*.interactive.e2e.test.ts"],
|
||||
exclude: [
|
||||
"src/**/*.interactive.e2e.test.ts",
|
||||
"src/**/*.tuistory.e2e.test.ts",
|
||||
],
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.tuistory.e2e.test.ts"],
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.101.0"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { BannerService } from "@/services/banner/BannerService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { getExtensionVariant } from "@/services/telemetry/rollout-metadata"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
import { getClineOnboardingModels } from "../models/getClineOnboardingModels"
|
||||
|
||||
@@ -109,6 +110,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
|
||||
return {
|
||||
version,
|
||||
extensionVariant: getExtensionVariant(),
|
||||
apiConfiguration,
|
||||
currentTaskItem,
|
||||
clineMessages,
|
||||
|
||||
@@ -315,6 +315,34 @@ describe("TerminalProcess (Integration Tests)", () => {
|
||||
process.getCompletionDetails().exitCode?.should.equal(1)
|
||||
})
|
||||
|
||||
it("completes when the shell execution ends while the read stream remains open", async () => {
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
const mockExecution = { read: () => createHangingStream([OSC633_C, "test output\n"]) }
|
||||
const mockExecuteCommand = sandbox.stub().returns(mockExecution)
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({ executeCommand: mockExecuteCommand }))
|
||||
|
||||
let endListener: ((e: vscode.TerminalShellExecutionEndEvent) => unknown) | undefined
|
||||
sandbox.stub(vscode.window, "onDidEndTerminalShellExecution").callsFake((listener) => {
|
||||
endListener = listener
|
||||
return { dispose: () => {} }
|
||||
})
|
||||
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
const runPromise = process.run(terminal, "echo test")
|
||||
await sandbox.clock.tickAsync(0)
|
||||
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.false()
|
||||
endListener?.({ terminal, execution: mockExecution, exitCode: 0 } as unknown as vscode.TerminalShellExecutionEndEvent)
|
||||
await runPromise
|
||||
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "test output").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
process.getCompletionDetails().exitCode?.should.equal(0)
|
||||
})
|
||||
|
||||
it("falls back to no exit code when onDidEndTerminalShellExecution never fires", async () => {
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
@@ -26,8 +26,13 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import { Osc633EventType, Osc633Parser } from "./osc633Parser"
|
||||
import { classifyShellPrompt, getLastLine } from "./shellPromptHeuristics"
|
||||
|
||||
/** Outcome of racing one stream read against the markerless-completion timers. */
|
||||
type StreamReadOutcome = { kind: "data"; data: string } | { kind: "streamEnd" } | { kind: "idle" } | { kind: "terminalClosed" }
|
||||
/** Outcome of racing one stream read against command-completion signals. */
|
||||
type StreamReadOutcome =
|
||||
| { kind: "data"; data: string }
|
||||
| { kind: "streamEnd" }
|
||||
| { kind: "executionEnd" }
|
||||
| { kind: "idle" }
|
||||
| { kind: "terminalClosed" }
|
||||
|
||||
/**
|
||||
* VscodeTerminalProcess - Manages command execution in VSCode's integrated terminal.
|
||||
@@ -111,11 +116,9 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
let preCommandBuffer = "" // text before C; emitted as fallback if C never arrives
|
||||
let didEmitEmptyLine = false
|
||||
|
||||
// Listen for the shell execution end event to capture the exit code.
|
||||
// This is the reliable source — the D marker is stripped from the stream.
|
||||
// The event fires asynchronously AFTER the read() stream completes (VS Code
|
||||
// calls flush().then(() => fire(endEvent))), so we must await it rather
|
||||
// than checking synchronously.
|
||||
// Listen for the shell execution end event to capture the exit code and
|
||||
// independently signal completion. The event normally follows the stream,
|
||||
// but some shells leave read() open after reporting that execution ended.
|
||||
//
|
||||
// onDidEndTerminalShellExecution has been stable API since VS Code 1.93,
|
||||
// below our minimum supported version (see package.json engines.vscode), so it is
|
||||
@@ -125,10 +128,10 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
// has shell integration present but may never trigger this event for that
|
||||
// execution. That case is bounded by the exit-code race below, not by
|
||||
// feature-detecting the event itself.
|
||||
const resolveExitCode = Promise.withResolvers<number | undefined>()
|
||||
const resolveExecutionEnd = Promise.withResolvers<number | undefined>()
|
||||
const endEventDisposable = vscode.window.onDidEndTerminalShellExecution((e) => {
|
||||
if (e.terminal === terminal && e.execution === execution) {
|
||||
resolveExitCode.resolve(e.exitCode)
|
||||
resolveExecutionEnd.resolve(e.exitCode)
|
||||
}
|
||||
})
|
||||
this.activeEndEventDisposable = endEventDisposable
|
||||
@@ -165,6 +168,7 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
(result): StreamReadOutcome =>
|
||||
result.done ? { kind: "streamEnd" } : { kind: "data", data: result.value },
|
||||
),
|
||||
resolveExecutionEnd.promise.then((): StreamReadOutcome => ({ kind: "executionEnd" })),
|
||||
terminalClosedPromise.then((): StreamReadOutcome => ({ kind: "terminalClosed" })),
|
||||
]
|
||||
if (idleTimeoutMs !== undefined) {
|
||||
@@ -211,7 +215,7 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
: MARKERLESS_FIRST_DATA_TIMEOUT
|
||||
const outcome = await readNext(idleTimeoutMs)
|
||||
|
||||
if (outcome.kind === "streamEnd") {
|
||||
if (outcome.kind === "streamEnd" || outcome.kind === "executionEnd") {
|
||||
break
|
||||
}
|
||||
if (outcome.kind === "terminalClosed") {
|
||||
@@ -350,16 +354,14 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
this.activeIterator = undefined
|
||||
this.emitRemainingBufferIfListening()
|
||||
|
||||
// Await the exit code from onDidEndTerminalShellExecution.
|
||||
// The event fires asynchronously AFTER the read() stream completes
|
||||
// (VS Code calls flush().then(() => fire(endEvent))), so we must
|
||||
// await it here. Race with a timeout in case the event never fires —
|
||||
// Await the exit code from onDidEndTerminalShellExecution. Race with a
|
||||
// timeout in case the stream ended but the event never fires —
|
||||
// this happens when shell integration is attached but not reporting
|
||||
// completion for this execution (e.g. commands typed into a remote
|
||||
// ssh session), not because the API is unavailable.
|
||||
let exitCodeEventTimedOut = false
|
||||
const eventExitCode = await Promise.race([
|
||||
resolveExitCode.promise,
|
||||
resolveExecutionEnd.promise,
|
||||
new Promise<undefined>((resolve) => {
|
||||
setTimeout(() => {
|
||||
exitCodeEventTimedOut = true
|
||||
|
||||
@@ -1053,6 +1053,12 @@ export class Controller {
|
||||
requestId: clineError.requestId,
|
||||
errorType: event.errorType,
|
||||
failurePhase: event.failurePhase,
|
||||
// Every event here is a failure the user actually saw: transient
|
||||
// errors are retried inside the provider layer before any event
|
||||
// reaches this adapter, and recoverable in-run notices are filtered
|
||||
// out upstream. The legacy extension applies the same
|
||||
// surfaced-failures-only rule at its emission sites, so the A/B
|
||||
// cohorts compare directly with no query-side filtering.
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1438,6 +1444,12 @@ export class Controller {
|
||||
})
|
||||
}
|
||||
|
||||
// The edit supersedes the old session — settle any pending tool
|
||||
// approval / ask_question exactly like cancelTask does. Without this,
|
||||
// the old run stays suspended forever on a promise nothing can
|
||||
// resolve, and the stale parked resolver intercepts later responses.
|
||||
this.interactions.clearPending("Superseded by an edited message")
|
||||
|
||||
const { startResult, sdkHost } = await this.sessions.startNewSession(startInput)
|
||||
|
||||
this.turnStateTracker.set("streaming")
|
||||
|
||||
@@ -264,6 +264,13 @@ describe("normalizeSdkBaseUrl", () => {
|
||||
it("preserves explicit user paths", () => {
|
||||
expect(normalizeSdkBaseUrl("openai", " https://example.com/custom ")).toBe("https://example.com/custom")
|
||||
})
|
||||
|
||||
it("inherits the AskSage default /server path when the custom URL has no path", () => {
|
||||
expect(normalizeSdkBaseUrl("asksage", "https://asksage.internal.example")).toBe("https://asksage.internal.example/server")
|
||||
expect(normalizeSdkBaseUrl("asksage", "https://asksage.internal.example/custom")).toBe(
|
||||
"https://asksage.internal.example/custom",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -455,6 +462,52 @@ describe("buildSessionConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("resolves the AskSage base URL from the legacy asksageApiUrl state field", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "asksage",
|
||||
actModeApiModelId: "gpt-4o",
|
||||
asksageApiKey: "asksage-key",
|
||||
asksageApiUrl: "https://asksage.internal.example/server",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerId).toBe("asksage")
|
||||
// Without this mapping the custom URL saved in legacy state was
|
||||
// silently ignored and requests went to the builtin default
|
||||
// (https://api.asksage.ai/server).
|
||||
expect(config.baseUrl).toBe("https://asksage.internal.example/server")
|
||||
expect(config.providerConfig).toMatchObject({
|
||||
providerId: "asksage",
|
||||
baseUrl: "https://asksage.internal.example/server",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to the providers.json AskSage base URL when legacy state has none", async () => {
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
|
||||
if (providerId !== "asksage") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "asksage",
|
||||
apiKey: "asksage-key",
|
||||
baseUrl: "https://asksage.migrated.example/server",
|
||||
} as any
|
||||
})
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "asksage",
|
||||
actModeApiModelId: "gpt-4o",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.baseUrl).toBe("https://asksage.migrated.example/server")
|
||||
expect(config.providerConfig).toMatchObject({
|
||||
providerId: "asksage",
|
||||
baseUrl: "https://asksage.migrated.example/server",
|
||||
})
|
||||
})
|
||||
|
||||
it("forwards the regional API line from legacy state so the gateway can route to the regional endpoint", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "zai",
|
||||
|
||||
@@ -631,6 +631,7 @@ export function resolveBaseUrl(providerId: string, config: ApiConfiguration): st
|
||||
gemini: "geminiBaseUrl",
|
||||
requesty: "requestyBaseUrl",
|
||||
litellm: "liteLlmBaseUrl",
|
||||
asksage: "asksageApiUrl",
|
||||
oca: "ocaBaseUrl",
|
||||
aihubmix: "aihubmixBaseUrl",
|
||||
dify: "difyBaseUrl",
|
||||
|
||||
@@ -183,6 +183,26 @@ describe("createProviderConfigStore", () => {
|
||||
expect(store.read(providerId).baseUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
// Changing the regional API line in the settings UI goes through
|
||||
// store.write. It must land in providers.json (the CLI and desktop app
|
||||
// bake the regional base URL from its stored apiLine) AND mirror to the
|
||||
// legacy state key (the VS Code session factory's resolveApiLine reads
|
||||
// legacy state first).
|
||||
it.each([
|
||||
["qwen", "qwenApiLine"],
|
||||
["moonshot", "moonshotApiLine"],
|
||||
] as const)("mirrors %s apiLine writes to both providers.json and the legacy state key", async (provider, legacyKey) => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId(provider)
|
||||
|
||||
store.write(providerId, { apiLine: "china" })
|
||||
|
||||
expect(mocks.getSavedProviderSettings(provider)).toMatchObject({ provider, apiLine: "china" })
|
||||
expect(mocks.getApiConfiguration()[legacyKey]).toBe("china")
|
||||
expect(store.read(providerId).apiLine).toBe("china")
|
||||
})
|
||||
|
||||
it("round-trips commitSelection then readSelection for provider-specific model info", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
|
||||
@@ -82,4 +82,40 @@ describe("buildSdkProviderConfig", () => {
|
||||
})
|
||||
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("v0")
|
||||
})
|
||||
|
||||
it("forwards the Ollama request timeout and context window to standalone handlers", () => {
|
||||
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "ollama",
|
||||
actModeOllamaModelId: "qwen2.5:7b",
|
||||
requestTimeoutMs: 45_000,
|
||||
ollamaApiOptionsCtxNum: "16384",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "ollama",
|
||||
modelId: "qwen2.5:7b",
|
||||
timeoutMs: 45_000,
|
||||
modelInfo: { id: "qwen2.5:7b", contextWindow: 16384 },
|
||||
})
|
||||
})
|
||||
|
||||
it("omits timeoutMs for Ollama when no explicit timeout is configured", () => {
|
||||
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "ollama",
|
||||
actModeOllamaModelId: "qwen2.5:7b",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig.providerId).toBe("ollama")
|
||||
expect("timeoutMs" in providerConfig).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,13 @@ import type { Mode } from "@shared/storage/types"
|
||||
import { reasoningEffortFromThinkingBudget } from "@shared/utils/reasoning-support"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { buildBedrockProviderConfig } from "./bedrock-config"
|
||||
import { resolveApiKey, resolveBaseUrl, resolveModelId, resolveVertexProviderConfig } from "./cline-session-factory"
|
||||
import {
|
||||
resolveApiKey,
|
||||
resolveBaseUrl,
|
||||
resolveModelId,
|
||||
resolveOllamaProviderConfig,
|
||||
resolveVertexProviderConfig,
|
||||
} from "./cline-session-factory"
|
||||
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
|
||||
|
||||
export interface BuildApiHandlerOptions {
|
||||
@@ -71,6 +77,11 @@ export function buildSdkProviderConfig(
|
||||
// Bedrock needs its region + structured AWS auth options forwarded to the
|
||||
// SDK gateway. Without these, a pasted Bedrock API key / region is dropped.
|
||||
...(providerId === "bedrock" ? buildBedrockProviderConfig(configuration, mode) : {}),
|
||||
// Ollama carries the user's request timeout and context window
|
||||
// (`num_ctx`) on the provider config; without this, standalone callers
|
||||
// ignore an explicit Request Timeout setting and load models with
|
||||
// Ollama's 4096-token server default.
|
||||
...(providerId === "ollama" ? resolveOllamaProviderConfig(configuration, modelId) : {}),
|
||||
}
|
||||
|
||||
if (options?.disableReasoning) {
|
||||
|
||||
@@ -313,6 +313,7 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
event: {
|
||||
type: "error",
|
||||
error,
|
||||
recoverable: false,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
@@ -344,6 +345,27 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not capture provider failure telemetry for recoverable error events (mistake notices)", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "error",
|
||||
// The MistakeTracker emits one of these per recorded mistake,
|
||||
// carrying tool-failure details — not a provider API error.
|
||||
error: new Error('2 tool call(s) failed: [shell] {"error":"command not found"}'),
|
||||
recoverable: true,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("captures provider failure telemetry when the SDK finishes a turn with reason error", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
|
||||
@@ -167,6 +167,17 @@ export class SdkSessionEventCoordinator {
|
||||
if (agentEvent.error == null) {
|
||||
return undefined
|
||||
}
|
||||
// Only terminal failures are provider failures. `recoverable: true`
|
||||
// error events are in-run notices — the MistakeTracker emits one for
|
||||
// EVERY recorded mistake (with the tool/mistake details as the
|
||||
// message, e.g. "2 tool call(s) failed: [shell] ...") and hook
|
||||
// failures surface the same way. Counting those here misclassified
|
||||
// tool noise as provider API errors and inflated the SDK bundle's
|
||||
// error rate ~9x vs legacy in the A/B rollout dashboards. Genuine
|
||||
// run failures (run-failed) always carry `recoverable: false`.
|
||||
if (agentEvent.recoverable !== false) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sessionId: event.payload.sessionId,
|
||||
error: agentEvent.error,
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners() // Triggers background fetch
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(mockFetch.calledOnce).to.be.true
|
||||
const banners = bannerService.getActiveBanners() // Get banners after fetch completes
|
||||
@@ -181,7 +181,7 @@ describe("BannerService", () => {
|
||||
const banners = bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
})
|
||||
@@ -318,7 +318,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -352,7 +352,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -386,7 +386,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -419,7 +419,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -453,7 +453,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -485,7 +485,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -515,7 +515,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -548,14 +548,14 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
expect(mockFetch.calledOnce).to.be.true
|
||||
|
||||
bannerService.clearCache()
|
||||
|
||||
bannerService.getActiveBanners()
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
expect(mockFetch.calledTwice).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -585,7 +585,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(mockFetch.calledOnce).to.be.true
|
||||
const call = mockFetch.getCall(0)
|
||||
@@ -624,7 +624,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -665,7 +665,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -713,7 +713,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(2)
|
||||
@@ -745,7 +745,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -778,7 +778,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
@@ -811,7 +811,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
@@ -854,7 +854,7 @@ describe("BannerService", () => {
|
||||
bannerService.getActiveBanners()
|
||||
|
||||
// Wait for background fetch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
const banners = bannerService.getActiveBanners()
|
||||
expect(mockedPostStateToWebview.called).to.be.true
|
||||
@@ -892,7 +892,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -905,7 +905,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -918,7 +918,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
|
||||
})
|
||||
@@ -931,7 +931,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
|
||||
})
|
||||
@@ -944,7 +944,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("cli")
|
||||
})
|
||||
@@ -957,7 +957,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -970,7 +970,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("vscode")
|
||||
})
|
||||
@@ -983,7 +983,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("unknown")
|
||||
})
|
||||
@@ -996,7 +996,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("unknown")
|
||||
})
|
||||
@@ -1009,7 +1009,7 @@ describe("BannerService", () => {
|
||||
await mockFetchForTesting(mockFetch, async () => {
|
||||
const bannerService = BannerService.initialize(mockController)
|
||||
bannerService.getActiveBanners()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await bannerService.drainForTesting()
|
||||
|
||||
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("ClineError", () => {
|
||||
|
||||
it("should return Entitlement for the SDK ClinePass subscription message", () => {
|
||||
const err = new ClineError(
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan:",
|
||||
)
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
@@ -19,7 +19,7 @@ describe("ClineError", () => {
|
||||
|
||||
it("should return Entitlement for the SDK ClinePass subscription message with a different app URL", () => {
|
||||
const err = new ClineError(
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan:",
|
||||
)
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
|
||||
@@ -28,7 +28,6 @@ import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mc
|
||||
import chokidar, { type FSWatcher } from "chokidar"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import { nanoid } from "nanoid"
|
||||
import ReconnectingEventSource from "reconnecting-eventsource"
|
||||
import { z } from "zod"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -94,11 +93,6 @@ export class McpHub {
|
||||
*/
|
||||
private lastConnectionFingerprint?: string
|
||||
|
||||
/**
|
||||
* Map of unique keys to each connected server names
|
||||
*/
|
||||
private static mcpServerKeys = new Map<string, string>()
|
||||
|
||||
// Store notifications for display in chat
|
||||
private pendingNotifications: Array<{
|
||||
serverName: string
|
||||
@@ -143,33 +137,6 @@ export class McpHub {
|
||||
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MCP server name from its unique key.
|
||||
* If the key is not found, return the key itself.
|
||||
*/
|
||||
public static getMcpServerByKey(key: string): string {
|
||||
return McpHub.mcpServerKeys.get(key) || key
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique key for an MCP server based on its name.
|
||||
* This avoids making a tool name too long while still ensuring uniqueness.
|
||||
*/
|
||||
private getMcpServerKey(server: string): string {
|
||||
// Reuse existing key if server is already registered
|
||||
for (const [existingKey, existingServer] of McpHub.mcpServerKeys.entries()) {
|
||||
if (existingServer === server) {
|
||||
return existingKey
|
||||
}
|
||||
}
|
||||
// Generate a short 6-character unique ID for the server
|
||||
// Add c prefix to ensure it starts with a letter (for compatibility with Gemini)
|
||||
// Only use the first 5 characters of nanoid to keep it short
|
||||
const uid = "c" + nanoid(5)
|
||||
McpHub.mcpServerKeys.set(uid, server)
|
||||
return uid
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the MCP settings file
|
||||
* @returns Path to the MCP settings file
|
||||
@@ -497,7 +464,6 @@ export class McpHub {
|
||||
const connection = this.findConnection(name, source)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
McpHub.mcpServerKeys.delete(connection.server.uid || name)
|
||||
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
@@ -507,7 +473,6 @@ export class McpHub {
|
||||
const connection = this.findConnection(name, source)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
McpHub.mcpServerKeys.delete(connection.server.uid || name)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
@@ -579,7 +544,6 @@ export class McpHub {
|
||||
const connection = this.findConnection(name, source)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
McpHub.mcpServerKeys.delete(connection.server.uid || name)
|
||||
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
@@ -622,7 +586,6 @@ export class McpHub {
|
||||
connectToServer: () => this.connectToServer(name, config, source),
|
||||
notifyWebviewOfServerChanges: () => this.notifyWebviewOfServerChanges(),
|
||||
appendErrorMessage: (conn, msg) => this.appendErrorMessage(conn as McpConnection, msg),
|
||||
deleteServerKey: (uid) => McpHub.mcpServerKeys.delete(uid),
|
||||
delay: (ms) => setTimeoutPromise(ms),
|
||||
})
|
||||
|
||||
@@ -639,7 +602,6 @@ export class McpHub {
|
||||
config: configForStorage,
|
||||
status: "connecting",
|
||||
disabled: config.disabled,
|
||||
uid: this.getMcpServerKey(name),
|
||||
oauthRequired: false,
|
||||
oauthAuthStatus: "authenticated",
|
||||
},
|
||||
@@ -666,7 +628,6 @@ export class McpHub {
|
||||
oauthRequired: true,
|
||||
oauthAuthStatus: "unauthenticated",
|
||||
error: "This MCP server requires authentication to get started.",
|
||||
uid: this.getMcpServerKey(name),
|
||||
},
|
||||
client,
|
||||
transport,
|
||||
@@ -771,7 +732,6 @@ export class McpHub {
|
||||
config: JSON.stringify(config),
|
||||
status: "disconnected",
|
||||
disabled: config.disabled,
|
||||
uid: this.getMcpServerKey(name),
|
||||
},
|
||||
client: null as unknown as Client,
|
||||
transport: null as unknown as Transport,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Logger } from "@/shared/services/Logger"
|
||||
*/
|
||||
export interface ReconnectCallbacks {
|
||||
/** Returns the current connection object, or undefined if it no longer exists */
|
||||
findConnection: () => { server: { status: string; disabled?: boolean; uid?: string } } | undefined
|
||||
findConnection: () => { server: { status: string; disabled?: boolean } } | undefined
|
||||
/** Tears down the existing connection */
|
||||
deleteConnection: () => Promise<void>
|
||||
/** Establishes a new connection */
|
||||
@@ -15,8 +15,6 @@ export interface ReconnectCallbacks {
|
||||
notifyWebviewOfServerChanges: () => Promise<void>
|
||||
/** Appends an error message to the connection's server object */
|
||||
appendErrorMessage: (connection: { server: { status: string } }, message: string) => void
|
||||
/** Removes the server key from the global registry */
|
||||
deleteServerKey: (uid: string) => void
|
||||
/** Awaitable delay — injected so tests can substitute a zero-delay or fake timer */
|
||||
delay: (ms: number) => Promise<void>
|
||||
}
|
||||
@@ -94,7 +92,6 @@ export class StreamableHttpReconnectHandler {
|
||||
`exhausted for "${this.serverName}". Server marked as disconnected.`,
|
||||
)
|
||||
connection.server.status = "disconnected"
|
||||
this.callbacks.deleteServerKey(connection.server.uid || this.serverName)
|
||||
this.callbacks.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
||||
await this.callbacks.notifyWebviewOfServerChanges()
|
||||
return
|
||||
@@ -157,7 +154,6 @@ export class StreamableHttpReconnectHandler {
|
||||
const exhaustedConnection = this.callbacks.findConnection()
|
||||
if (exhaustedConnection) {
|
||||
exhaustedConnection.server.status = "disconnected"
|
||||
this.callbacks.deleteServerKey(exhaustedConnection.server.uid || this.serverName)
|
||||
this.callbacks.appendErrorMessage(exhaustedConnection, error instanceof Error ? error.message : `${error}`)
|
||||
}
|
||||
await this.callbacks.notifyWebviewOfServerChanges()
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
} from "../StreamableHttpReconnectHandler"
|
||||
|
||||
/** Build a mock connection object whose status can be inspected. */
|
||||
function makeConnection(overrides: Partial<{ status: string; disabled: boolean; uid: string }> = {}) {
|
||||
function makeConnection(overrides: Partial<{ status: string; disabled: boolean }> = {}) {
|
||||
return {
|
||||
server: {
|
||||
status: overrides.status ?? "connected",
|
||||
disabled: overrides.disabled ?? false,
|
||||
uid: overrides.uid ?? "uid-123",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -31,7 +30,6 @@ function makeCallbacks(connection?: ReturnType<typeof makeConnection>): Reconnec
|
||||
connectToServer: sinon.stub().resolves(),
|
||||
notifyWebviewOfServerChanges: sinon.stub().resolves(),
|
||||
appendErrorMessage: sinon.stub(),
|
||||
deleteServerKey: sinon.stub(),
|
||||
delay: sinon.stub().resolves(), // instant — no real waiting in tests
|
||||
}
|
||||
return { ...(stubs as unknown as ReconnectCallbacks), stubs }
|
||||
@@ -148,7 +146,7 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
|
||||
// After deleteConnection, findConnection returns undefined (old conn deleted)
|
||||
// but connectToServer may leave a partial connection, so simulate that
|
||||
const partialConn = makeConnection({ uid: "uid-partial" })
|
||||
const partialConn = makeConnection()
|
||||
let deleted = false
|
||||
cbs.stubs.findConnection.callsFake(() => {
|
||||
if (!deleted) return conn
|
||||
@@ -170,7 +168,6 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
|
||||
// The partial connection should be marked disconnected
|
||||
partialConn.server.status.should.equal("disconnected")
|
||||
cbs.stubs.deleteServerKey.calledWith("uid-partial").should.be.true()
|
||||
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
|
||||
cbs.stubs.appendErrorMessage.firstCall.args[1].should.equal("transport error")
|
||||
})
|
||||
@@ -207,7 +204,6 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
await handler.handleError(new Error("final error"))
|
||||
|
||||
conn.server.status.should.equal("disconnected")
|
||||
cbs.stubs.deleteServerKey.calledWith("uid-123").should.be.true()
|
||||
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
|
||||
cbs.stubs.connectToServer.called.should.be.false()
|
||||
})
|
||||
@@ -240,7 +236,7 @@ describe("StreamableHttpReconnectHandler", () => {
|
||||
|
||||
it("should abort reconnect if connection was replaced during delay", async () => {
|
||||
const conn = makeConnection()
|
||||
const differentConn = makeConnection({ uid: "uid-replaced" })
|
||||
const differentConn = makeConnection()
|
||||
const cbs = makeCallbacks(conn)
|
||||
// After the delay, findConnection returns a different object
|
||||
cbs.stubs.findConnection.onFirstCall().returns(conn)
|
||||
|
||||
@@ -267,8 +267,6 @@ export class TelemetryService {
|
||||
WORKSPACE: {
|
||||
// Track workspace initialization
|
||||
INITIALIZED: "workspace.initialized",
|
||||
// Track initialization errors
|
||||
INIT_ERROR: "workspace.init_error",
|
||||
// Track VCS detection
|
||||
VCS_DETECTED: "workspace.vcs_detected",
|
||||
// Track multi-root checkpoint operations
|
||||
@@ -307,8 +305,6 @@ export class TelemetryService {
|
||||
LEGACY_TASK_MIGRATION: "task.legacy_task_migration",
|
||||
// Tracks when the retry button is clicked for failed operations
|
||||
RETRY_CLICKED: "task.retry_clicked",
|
||||
// Tracks when a diff edit (replace_in_file) operation fails
|
||||
DIFF_EDIT_FAILED: "task.diff_edit_failed",
|
||||
// Tracks when the browser tool is started
|
||||
BROWSER_TOOL_START: "task.browser_tool_start",
|
||||
// Tracks when the browser tool is completed
|
||||
@@ -1195,27 +1191,6 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a diff edit (replace_in_file) operation fails
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param modelId The model ID being used
|
||||
* @param provider The API provider being used
|
||||
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
|
||||
* @param isNativeToolCall Whether the diff edit was invoked by a native tool call
|
||||
*/
|
||||
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string, isNativeToolCall = false) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
|
||||
properties: {
|
||||
ulid,
|
||||
errorType,
|
||||
modelId,
|
||||
provider,
|
||||
isNativeToolCall,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a different model is selected for use
|
||||
* @param model Name of the selected model
|
||||
@@ -1915,24 +1890,6 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records workspace initialization errors
|
||||
* @param error The error that occurred
|
||||
* @param fallbackMode Whether system fell back to single-root mode
|
||||
* @param workspaceCount Number of workspace folders detected
|
||||
*/
|
||||
public captureWorkspaceInitError(error: Error, fallbackMode: boolean, workspaceCount?: number) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.WORKSPACE.INIT_ERROR,
|
||||
properties: {
|
||||
error_type: error.constructor.name,
|
||||
error_message: error.message.substring(0, MAX_ERROR_MESSAGE_LENGTH),
|
||||
fallback_to_single_root: fallbackMode,
|
||||
workspace_count: workspaceCount ?? 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records multi-root checkpoint operations
|
||||
* @param ulid Task identifier
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { getRolloutErrorProperties, getRolloutTelemetryMetadata, ROLLOUT_ERROR_MESSAGE_LIMIT } from "./rollout-metadata"
|
||||
import {
|
||||
getExtensionVariant,
|
||||
getRolloutErrorProperties,
|
||||
getRolloutTelemetryMetadata,
|
||||
ROLLOUT_ERROR_MESSAGE_LIMIT,
|
||||
} from "./rollout-metadata"
|
||||
|
||||
const originalVariant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
|
||||
@@ -24,6 +29,20 @@ describe("rollout telemetry metadata", () => {
|
||||
expect(getRolloutTelemetryMetadata()).toEqual({})
|
||||
})
|
||||
|
||||
it("exposes the variant for rollout builds only", () => {
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
|
||||
expect(getExtensionVariant()).toBe("legacy")
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "next"
|
||||
expect(getExtensionVariant()).toBe("next")
|
||||
|
||||
delete process.env.CLINE_ROLLOUT_VARIANT
|
||||
expect(getExtensionVariant()).toBeUndefined()
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "invalid"
|
||||
expect(getExtensionVariant()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("bounds fallback errors without including stacks", () => {
|
||||
const error = new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20))
|
||||
const properties = getRolloutErrorProperties(error)
|
||||
|
||||
@@ -14,15 +14,19 @@ export interface RolloutBundleActivation {
|
||||
export const ROLLOUT_BUNDLE_ACTIVATED_EVENT = "extension.rollout.bundle_activated"
|
||||
export const ROLLOUT_ERROR_MESSAGE_LIMIT = 500
|
||||
|
||||
/**
|
||||
* The rollout variant this bundle was built as, or undefined for ordinary builds.
|
||||
* CLINE_ROLLOUT_VARIANT is inlined at build time by the combined rollout workflow only.
|
||||
*/
|
||||
export function getExtensionVariant(): ExtensionVariant | undefined {
|
||||
const variant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
return variant === "legacy" || variant === "next" ? variant : undefined
|
||||
}
|
||||
|
||||
/** Return rollout metadata only for bundles built by the combined rollout workflow. */
|
||||
export function getRolloutTelemetryMetadata(): Partial<RolloutTelemetryMetadata> {
|
||||
const variant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
|
||||
if (variant !== "legacy" && variant !== "next") {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { extension_variant: variant }
|
||||
const variant = getExtensionVariant()
|
||||
return variant ? { extension_variant: variant } : {}
|
||||
}
|
||||
|
||||
export function getRolloutErrorProperties(error: unknown): {
|
||||
|
||||
@@ -100,6 +100,11 @@ export interface ExtensionState {
|
||||
lastCompletedCommandTs?: number
|
||||
userInfo?: UserInfo
|
||||
version: string
|
||||
/**
|
||||
* Which rollout bundle this build is ("legacy" or "next"). Only present for
|
||||
* bundles built by the combined rollout workflow; undefined for ordinary builds.
|
||||
*/
|
||||
extensionVariant?: "legacy" | "next"
|
||||
distinctId: string
|
||||
globalClineRulesToggles: ClineRulesToggles
|
||||
localClineRulesToggles: ClineRulesToggles
|
||||
|
||||
@@ -24,7 +24,6 @@ export type McpServer = {
|
||||
prompts?: McpPrompt[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
uid?: string
|
||||
oauthRequired?: boolean
|
||||
oauthAuthStatus?: McpOAuthAuthStatus
|
||||
}
|
||||
|
||||
@@ -50,6 +50,17 @@ export const E2E_MOCK_EDITOR_TOOL_CALL = {
|
||||
},
|
||||
}
|
||||
|
||||
/** Windows PowerShell diagnostic executed by the real background shell tool. */
|
||||
export const E2E_MOCK_POWERSHELL_TOOL_CALL = {
|
||||
id: "call_e2e_powershell_1",
|
||||
name: "run_commands",
|
||||
arguments: {
|
||||
commands: [
|
||||
"Write-Output ('VERSION=' + $PSVersionTable.PSVersion); Write-Output ('PSHOME=' + $PSHOME); Write-Output 'UNICODE=中文'",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const edit_request_complete = `I successfully replaced "john" with "cline" in the test.ts file. The change has been completed and the file now contains:
|
||||
|
||||
\`\`\`typescript
|
||||
@@ -64,6 +75,8 @@ export const E2E_MOCK_API_RESPONSES = {
|
||||
EDIT_REQUEST_LEAD_IN: `I'll replace "john" with "cline" in the test.ts file.`,
|
||||
/** Turn-ending text streamed after the SDK reports the editor tool result. */
|
||||
EDIT_REQUEST_COMPLETE: edit_request_complete,
|
||||
POWERSHELL_REQUEST_LEAD_IN: "I'll inspect the PowerShell process used for background execution.",
|
||||
POWERSHELL_REQUEST_COMPLETE: "PowerShell background execution diagnostic completed.",
|
||||
}
|
||||
|
||||
export const E2E_MOCK_CLINE_RECOMMENDED_MODELS = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
E2E_MOCK_CLINE_MODELS,
|
||||
E2E_MOCK_CLINE_RECOMMENDED_MODELS,
|
||||
E2E_MOCK_EDITOR_TOOL_CALL,
|
||||
E2E_MOCK_POWERSHELL_TOOL_CALL,
|
||||
E2E_REGISTERED_MOCK_ENDPOINTS,
|
||||
} from "./api"
|
||||
import { ClineDataMock } from "./data"
|
||||
@@ -480,27 +481,33 @@ export class ClineApiServerMock {
|
||||
// a `role: "tool"` message. Detect that follow-up first — the
|
||||
// original "edit_request" user prompt is still present in the
|
||||
// conversation history of the follow-up request, so order matters.
|
||||
// Scoped to edit_request conversations so tool results from other
|
||||
// (future) scenarios don't mis-route to EDIT_REQUEST_COMPLETE.
|
||||
// Scope tool-result routing to the mock scenarios that issued a tool
|
||||
// call so unrelated conversations retain the default response.
|
||||
const hasToolResult =
|
||||
body.includes("edit_request") &&
|
||||
(body.includes("edit_request") || body.includes("powershell_background_request")) &&
|
||||
Array.isArray(messages) &&
|
||||
messages.some((m: { role?: string }) => m?.role === "tool")
|
||||
|
||||
let responseText = E2E_MOCK_API_RESPONSES.DEFAULT
|
||||
let includeEditorToolCall = false
|
||||
let toolCall: typeof E2E_MOCK_EDITOR_TOOL_CALL | typeof E2E_MOCK_POWERSHELL_TOOL_CALL | undefined
|
||||
log("Chat completion mock selection:", {
|
||||
isEditRequest: body.includes("edit_request"),
|
||||
isPowerShellRequest: body.includes("powershell_background_request"),
|
||||
hasToolResult,
|
||||
})
|
||||
if (hasToolResult) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST_COMPLETE
|
||||
responseText = body.includes("powershell_background_request")
|
||||
? E2E_MOCK_API_RESPONSES.POWERSHELL_REQUEST_COMPLETE
|
||||
: E2E_MOCK_API_RESPONSES.EDIT_REQUEST_COMPLETE
|
||||
} else if (body.includes("edit_request")) {
|
||||
// Stream lead-in text followed by a structured `editor` tool
|
||||
// call (OpenAI tool_calls deltas) — the only tool-call syntax
|
||||
// the SDK runtime executes.
|
||||
responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST_LEAD_IN
|
||||
includeEditorToolCall = true
|
||||
toolCall = E2E_MOCK_EDITOR_TOOL_CALL
|
||||
} else if (body.includes("powershell_background_request")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.POWERSHELL_REQUEST_LEAD_IN
|
||||
toolCall = E2E_MOCK_POWERSHELL_TOOL_CALL
|
||||
}
|
||||
const generationId = `gen_${++controller.generationCounter}_${Date.now()}`
|
||||
|
||||
@@ -523,16 +530,16 @@ export class ClineApiServerMock {
|
||||
// for a tool_calls index must carry `id` + `function.name`;
|
||||
// `function.arguments` accumulates as string fragments. Split
|
||||
// the arguments JSON to exercise fragment reassembly.
|
||||
const argumentsJson = JSON.stringify(E2E_MOCK_EDITOR_TOOL_CALL.arguments)
|
||||
const argumentsJson = toolCall ? JSON.stringify(toolCall.arguments) : ""
|
||||
const argsSplitAt = Math.floor(argumentsJson.length / 2)
|
||||
const toolCallDeltas = includeEditorToolCall
|
||||
const toolCallDeltas = toolCall
|
||||
? [
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
id: E2E_MOCK_EDITOR_TOOL_CALL.id,
|
||||
id: toolCall.id,
|
||||
type: "function",
|
||||
function: { name: E2E_MOCK_EDITOR_TOOL_CALL.name, arguments: "" },
|
||||
function: { name: toolCall.name, arguments: "" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -600,7 +607,7 @@ export class ClineApiServerMock {
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: includeEditorToolCall ? "tool_calls" : "stop",
|
||||
finish_reason: toolCall ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
@@ -711,7 +718,11 @@ export class ClineApiServerMock {
|
||||
|
||||
handleRequest().catch((err) => {
|
||||
console.error("Request handling error:", err)
|
||||
sendApiError("Internal server error", 500)
|
||||
if (!res.headersSent) {
|
||||
sendApiError("Internal server error", 500)
|
||||
} else if (!res.writableEnded) {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync, lstatSync } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { expect } from "@playwright/test"
|
||||
import { e2e } from "./utils/helpers"
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
name: "Windows PowerShell 5.1",
|
||||
profileId: "powershell-legacy",
|
||||
profileName: "Windows PowerShell",
|
||||
version: /^VERSION=5\.1/,
|
||||
psHome: [/PSHOME=.*WindowsPowerShell/i, /v1\.0/i],
|
||||
},
|
||||
{
|
||||
name: "Store-installed PowerShell 7",
|
||||
profileId: "powershell-7",
|
||||
profileName: "PowerShell 7",
|
||||
version: /^VERSION=7\./,
|
||||
psHome: [/PSHOME=.*WindowsApps.*Microsoft\.PowerShell_/i],
|
||||
storeOnly: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
e2e(`Terminal - background execution uses ${profile.name}`, async ({ helper, page, sidebar }, testInfo) => {
|
||||
e2e.skip(process.platform !== "win32", "PowerShell background execution is Windows-specific")
|
||||
if ("storeOnly" in profile) {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
const storeAlias = path.join(process.env.LOCALAPPDATA ?? "", "Microsoft", "WindowsApps", "pwsh.exe")
|
||||
const hasPreferredInstall = [
|
||||
path.join(programFiles, "PowerShell", "7", "pwsh.exe"),
|
||||
path.join(programFiles, "PowerShell", "6", "pwsh.exe"),
|
||||
].some(existsSync)
|
||||
let hasStoreAlias = false
|
||||
try {
|
||||
hasStoreAlias = lstatSync(storeAlias).isSymbolicLink()
|
||||
} catch {
|
||||
// The Store alias is not installed or enabled.
|
||||
}
|
||||
e2e.skip(!hasStoreAlias || hasPreferredInstall, "Requires a Store-only PowerShell 7 installation")
|
||||
}
|
||||
|
||||
await helper.signin(sidebar)
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await sidebar.getByTestId("tab-terminal").click()
|
||||
await expect(sidebar.getByText("Terminal Settings", { exact: true })).toBeVisible()
|
||||
|
||||
const executionMode = sidebar.locator("#terminal-execution-mode")
|
||||
await executionMode.click()
|
||||
await sidebar.getByRole("option", { name: "Background Exec" }).click()
|
||||
|
||||
const terminalProfile = sidebar.locator("#default-terminal-profile")
|
||||
await terminalProfile.click()
|
||||
await sidebar.getByRole("option", { name: profile.profileName, exact: true }).click()
|
||||
|
||||
await expect(executionMode).toHaveAttribute("current-value", "backgroundExec")
|
||||
await expect(terminalProfile).toHaveAttribute("current-value", profile.profileId)
|
||||
await page.screenshot({ path: testInfo.outputPath("powershell-background-settings.png"), fullPage: true })
|
||||
|
||||
await sidebar.getByRole("button", { name: "Done" }).click()
|
||||
const inputbox = sidebar.getByTestId("chat-input")
|
||||
await inputbox.fill("powershell_background_request")
|
||||
await sidebar.getByTestId("send-button").click()
|
||||
await sidebar.getByRole("button", { name: "Run Command" }).click()
|
||||
|
||||
const commandOutput = sidebar.locator("code").filter({ hasText: profile.version })
|
||||
await expect(commandOutput).toBeVisible({ timeout: 30_000 })
|
||||
for (const expectedPathPart of profile.psHome) {
|
||||
await expect(commandOutput).toContainText(expectedPathPart)
|
||||
}
|
||||
await expect(commandOutput).toContainText("UNICODE=中文")
|
||||
await expect(sidebar.getByText("PowerShell background execution diagnostic completed.")).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("powershell-background-success.png"), fullPage: true })
|
||||
})
|
||||
}
|
||||
@@ -21,12 +21,14 @@ mock.module("node:os", osMock)
|
||||
// control which PowerShell installs "exist" regardless of the host machine.
|
||||
let existsSyncImpl: typeof actualFs.existsSync = actualFs.existsSync
|
||||
const existsSyncDelegate = ((path: unknown) => existsSyncImpl(path as string)) as typeof actualFs.existsSync
|
||||
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate }
|
||||
let lstatSyncImpl: typeof actualFs.lstatSync = actualFs.lstatSync
|
||||
const lstatSyncDelegate = ((path: unknown) => lstatSyncImpl(path as string)) as typeof actualFs.lstatSync
|
||||
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate, lstatSync: lstatSyncDelegate }
|
||||
const fsMock = () => ({ ...fsMockNamespace, default: fsMockNamespace })
|
||||
mock.module("fs", fsMock)
|
||||
mock.module("node:fs", fsMock)
|
||||
|
||||
import { getShell } from "@utils/shell"
|
||||
import { getShell, getShellForProfile } from "@utils/shell"
|
||||
|
||||
describe("Shell Detection Tests", () => {
|
||||
let originalPlatform: string
|
||||
@@ -34,6 +36,7 @@ describe("Shell Detection Tests", () => {
|
||||
let originalGetConfig: typeof vscode.workspace.getConfiguration
|
||||
let originalUserInfo: typeof actualOs.userInfo
|
||||
let originalExistsSync: typeof actualFs.existsSync
|
||||
let originalLstatSync: typeof actualFs.lstatSync
|
||||
|
||||
// Helper to mock VS Code configuration
|
||||
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
|
||||
@@ -58,6 +61,7 @@ describe("Shell Detection Tests", () => {
|
||||
originalGetConfig = vscode.workspace.getConfiguration
|
||||
originalUserInfo = userInfoImpl
|
||||
originalExistsSync = existsSyncImpl
|
||||
originalLstatSync = lstatSyncImpl
|
||||
|
||||
// Clear environment variables for a clean test
|
||||
delete process.env.SHELL
|
||||
@@ -68,6 +72,9 @@ describe("Shell Detection Tests", () => {
|
||||
// Default: PowerShell 7 is not installed, so the Windows default
|
||||
// resolves to legacy Windows PowerShell.
|
||||
existsSyncImpl = (() => false) as any
|
||||
lstatSyncImpl = (() => {
|
||||
throw new Error("ENOENT")
|
||||
}) as typeof actualFs.lstatSync
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -77,6 +84,7 @@ describe("Shell Detection Tests", () => {
|
||||
vscode.workspace.getConfiguration = originalGetConfig
|
||||
userInfoImpl = originalUserInfo
|
||||
existsSyncImpl = originalExistsSync
|
||||
lstatSyncImpl = originalLstatSync
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -146,12 +154,45 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { source: "PowerShell" },
|
||||
})
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("uses Store-installed pwsh for a source-based PowerShell profile", () => {
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
|
||||
existsSyncImpl = (() => false) as typeof actualFs.existsSync
|
||||
lstatSyncImpl = ((candidate: actualFs.PathLike) => {
|
||||
if (candidate !== storePwsh) {
|
||||
throw new Error("ENOENT")
|
||||
}
|
||||
return { isSymbolicLink: () => true } as actualFs.Stats
|
||||
}) as typeof actualFs.lstatSync
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { source: "PowerShell" },
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal(storePwsh)
|
||||
})
|
||||
|
||||
it("uses Store-installed pwsh for Cline's PowerShell 7 profile", () => {
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
|
||||
existsSyncImpl = (() => false) as typeof actualFs.existsSync
|
||||
lstatSyncImpl = ((candidate: actualFs.PathLike) => {
|
||||
if (candidate !== storePwsh) {
|
||||
throw new Error("ENOENT")
|
||||
}
|
||||
return { isSymbolicLink: () => true } as actualFs.Stats
|
||||
}) as typeof actualFs.lstatSync
|
||||
|
||||
expect(getShellForProfile("powershell-7")).to.equal(storePwsh)
|
||||
})
|
||||
|
||||
it("falls back to legacy PowerShell if profile includes 'powershell' but no path/source", () => {
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: {},
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import { exec } from "node:child_process"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
import "should"
|
||||
import { getGitDiff } from "../git"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
describe("getGitDiff", () => {
|
||||
let repoDir: string
|
||||
|
||||
async function git(args: string): Promise<void> {
|
||||
await execAsync(`git ${args}`, { cwd: repoDir })
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
repoDir = await mkdtemp(path.join(tmpdir(), "cline-git-diff-"))
|
||||
await git("init")
|
||||
await git('config user.email "test@example.com"')
|
||||
await git('config user.name "Test"')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("includes untracked files when nothing is staged (add-only working tree)", async () => {
|
||||
// Reproduces #12060: a brand-new file that has never been staged is
|
||||
// invisible to `git diff` and `git diff --staged`, so commit-message
|
||||
// generation used to fail with "No changes in workspace for commit message".
|
||||
await writeFile(path.join(repoDir, "new-file.txt"), "hello from an untracked file\n")
|
||||
|
||||
const diff = await getGitDiff(repoDir, false)
|
||||
|
||||
diff.should.match(/new-file\.txt/)
|
||||
diff.should.match(/hello from an untracked file/)
|
||||
})
|
||||
|
||||
it("includes untracked files alongside an existing commit", async () => {
|
||||
await writeFile(path.join(repoDir, "tracked.txt"), "tracked\n")
|
||||
await git("add tracked.txt")
|
||||
await git('commit -m "initial"')
|
||||
await writeFile(path.join(repoDir, "untracked.txt"), "brand new\n")
|
||||
|
||||
const diff = await getGitDiff(repoDir, false)
|
||||
|
||||
diff.should.match(/untracked\.txt/)
|
||||
diff.should.match(/brand new/)
|
||||
})
|
||||
|
||||
it("handles untracked filenames with spaces and shell metacharacters", async () => {
|
||||
// The filename is passed as a separate argv entry (no shell), so a name
|
||||
// containing spaces and `$` must not break the diff or execute anything.
|
||||
const trickyName = "a file with $VAR and spaces.txt"
|
||||
await writeFile(path.join(repoDir, trickyName), "content of the tricky file\n")
|
||||
|
||||
const diff = await getGitDiff(repoDir, false)
|
||||
|
||||
diff.should.match(/content of the tricky file/)
|
||||
})
|
||||
|
||||
it("includes both a tracked unstaged edit and an untracked file", async () => {
|
||||
// A modified tracked file makes `git diff HEAD` non-empty; the new file must
|
||||
// still be appended so the commit message covers the whole working tree.
|
||||
await writeFile(path.join(repoDir, "tracked.txt"), "original\n")
|
||||
await git("add tracked.txt")
|
||||
await git('commit -m "initial"')
|
||||
await writeFile(path.join(repoDir, "tracked.txt"), "original\nmodified line\n")
|
||||
await writeFile(path.join(repoDir, "brand-new.txt"), "the new file\n")
|
||||
|
||||
const diff = await getGitDiff(repoDir, false)
|
||||
|
||||
diff.should.match(/modified line/)
|
||||
diff.should.match(/brand-new\.txt/)
|
||||
diff.should.match(/the new file/)
|
||||
})
|
||||
|
||||
it("prefers staged changes over untracked files", async () => {
|
||||
await writeFile(path.join(repoDir, "staged.txt"), "staged content\n")
|
||||
await git("add staged.txt")
|
||||
await writeFile(path.join(repoDir, "untracked.txt"), "untracked content\n")
|
||||
|
||||
const diff = await getGitDiff(repoDir, true)
|
||||
|
||||
diff.should.match(/staged\.txt/)
|
||||
diff.should.not.match(/untracked\.txt/)
|
||||
})
|
||||
|
||||
it("throws when there are no changes at all", async () => {
|
||||
await writeFile(path.join(repoDir, "committed.txt"), "done\n")
|
||||
await git("add committed.txt")
|
||||
await git('commit -m "only commit"')
|
||||
|
||||
let error: Error | undefined
|
||||
try {
|
||||
await getGitDiff(repoDir, false)
|
||||
} catch (e) {
|
||||
error = e as Error
|
||||
}
|
||||
;(error !== undefined).should.be.true()
|
||||
error!.message.should.equal("No changes in workspace for commit message")
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
import { exec } from "child_process"
|
||||
import { exec, execFile } from "child_process"
|
||||
import { promisify } from "util"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
const execFileAsync = promisify(execFile)
|
||||
const GIT_OUTPUT_LINE_LIMIT = 500
|
||||
|
||||
// A human-readable label for the returned output header, not a runnable command
|
||||
// (each untracked file is diffed separately against /dev/null).
|
||||
const UNTRACKED_DIFF_LABEL = "git diff --no-index (untracked files)"
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
@@ -214,6 +219,17 @@ export async function getGitDiff(cwd: string, stagedOnly = false): Promise<strin
|
||||
diff = unstaged.trim()
|
||||
}
|
||||
|
||||
// `git diff` never reports untracked (new, never-staged) files, so they are
|
||||
// missing from both diffs above. Append them in the non-staged path so an
|
||||
// add-only working tree works AND a mix of edited + new files includes both.
|
||||
if (!stagedOnly) {
|
||||
const untracked = await getUntrackedFilesDiff(cwd)
|
||||
if (untracked) {
|
||||
diff = diff ? `${diff}\n\n${untracked}` : untracked
|
||||
command = diff === untracked ? UNTRACKED_DIFF_LABEL : `${command} + ${UNTRACKED_DIFF_LABEL}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
throw new Error("No changes in workspace for commit message")
|
||||
}
|
||||
@@ -224,6 +240,46 @@ export async function getGitDiff(cwd: string, stagedOnly = false): Promise<strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a diff for untracked (new, never-staged) files, which `git diff` and
|
||||
* `git diff --staged` both omit. Each file is diffed against an empty file via
|
||||
* `git diff --no-index` so the output looks like a normal added-file diff.
|
||||
* Returns an empty string when there are no untracked files.
|
||||
*/
|
||||
async function getUntrackedFilesDiff(cwd: string): Promise<string> {
|
||||
// `-z` returns NUL-separated, unquoted paths so filenames with spaces or
|
||||
// special characters survive intact.
|
||||
const { stdout: list } = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd })
|
||||
const files = list.split("\0").filter((file) => file.length > 0)
|
||||
if (files.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const diffs: string[] = []
|
||||
for (const file of files) {
|
||||
// Pass the filename as a separate argv entry (no shell) so names containing
|
||||
// quotes, `$`, backticks, or spaces can't be interpreted as shell syntax.
|
||||
// `git diff --no-index` exits 1 when the files differ (the normal case here),
|
||||
// which rejects the promise — capture stdout from that. Exit 2 is a real git
|
||||
// error (unreadable file, bad install), so re-throw it instead of swallowing.
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["--no-pager", "diff", "--no-index", "--diff-filter=d", "--", "/dev/null", file],
|
||||
{ cwd },
|
||||
).catch((error: { code?: number; stdout?: string }) => {
|
||||
if (error.code === 1) {
|
||||
return { stdout: error.stdout ?? "" }
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const trimmed = stdout.trim()
|
||||
if (trimmed) {
|
||||
diffs.push(trimmed)
|
||||
}
|
||||
}
|
||||
return diffs.join("\n\n")
|
||||
}
|
||||
|
||||
export async function getGitRemoteUrls(cwd: string): Promise<string[]> {
|
||||
try {
|
||||
const isInstalled = await checkGitInstalled()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, lstatSync } from "fs"
|
||||
import { userInfo } from "os"
|
||||
import * as nodePath from "path"
|
||||
import * as vscode from "vscode"
|
||||
@@ -191,8 +191,10 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
return configuredShell
|
||||
}
|
||||
if (profile?.source === "PowerShell") {
|
||||
// If the profile is sourced from PowerShell, assume the newest
|
||||
return SHELL_PATHS.POWERSHELL_7
|
||||
// A source-based profile delegates install detection to VS Code.
|
||||
// Mirror that detection for background execution rather than assuming
|
||||
// PowerShell 7 was installed to the MSI path.
|
||||
return getWindowsDefaultShell()
|
||||
}
|
||||
// Otherwise, assume legacy Windows PowerShell
|
||||
return SHELL_PATHS.POWERSHELL_LEGACY
|
||||
@@ -287,7 +289,7 @@ export function getAvailableTerminalProfiles(): TerminalProfile[] {
|
||||
{
|
||||
id: "powershell-7",
|
||||
name: "PowerShell 7",
|
||||
path: SHELL_PATHS.POWERSHELL_7,
|
||||
path: getInstalledWindowsPwsh() ?? SHELL_PATHS.POWERSHELL_7,
|
||||
description: "PowerShell 7 (pwsh.exe)",
|
||||
},
|
||||
{
|
||||
@@ -398,6 +400,22 @@ export function getWindowsPwshInstallPaths(): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** Returns an installed modern PowerShell executable, if one can be found. */
|
||||
function getInstalledWindowsPwsh(): string | undefined {
|
||||
return getWindowsPwshInstallPaths().find((candidate) => {
|
||||
if (existsSync(candidate)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
// Microsoft Store App Execution Aliases are zero-byte reparse points.
|
||||
// Node can spawn them, but existsSync() reports false.
|
||||
return lstatSync(candidate).isSymbolicLink()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell VS Code launches on Windows when the user has not configured a
|
||||
* default terminal profile: its built-in default is PowerShell (pwsh when
|
||||
@@ -406,8 +424,7 @@ export function getWindowsPwshInstallPaths(): string[] {
|
||||
* run in a visible VS Code terminal or a background child process.
|
||||
*/
|
||||
function getWindowsDefaultShell(): string {
|
||||
const pwsh = getWindowsPwshInstallPaths().find((candidate) => existsSync(candidate))
|
||||
return pwsh ?? SHELL_PATHS.POWERSHELL_LEGACY
|
||||
return getInstalledWindowsPwsh() ?? SHELL_PATHS.POWERSHELL_LEGACY
|
||||
}
|
||||
|
||||
export function getShell(): string {
|
||||
|
||||
@@ -587,7 +587,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
event.preventDefault()
|
||||
|
||||
if (!sendingDisabled) {
|
||||
setIsTextAreaFocused(false)
|
||||
// Note: don't set isTextAreaFocused to false here. The textarea keeps
|
||||
// DOM focus after sending, and clearing the flag without an actual
|
||||
// blur desyncs it permanently (programmatic .focus() on an
|
||||
// already-focused element never re-fires onFocus), which hides the
|
||||
// plan/act mode outline until a real blur/refocus cycle.
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
@@ -1581,7 +1585,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
data-testid="send-button"
|
||||
onClick={() => {
|
||||
if (!sendingDisabled) {
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -220,7 +220,7 @@ export const ClinePassEntitlementError: Story = {
|
||||
message: createMockMessage(),
|
||||
errorType: "error",
|
||||
apiRequestFailedMessage:
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan:",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("ErrorRow", () => {
|
||||
|
||||
it("renders entitlement error when ClineError detects ClineNotSubscribedError", async () => {
|
||||
const cliMessage =
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true"
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan:"
|
||||
const mockClineError = {
|
||||
message: cliMessage,
|
||||
isErrorType: vi.fn((type) => type === "entitlement"),
|
||||
|
||||
@@ -18,12 +18,16 @@ interface UserMessageProps {
|
||||
const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageTs, canRestoreWorkspace = true }) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedText, setEditedText] = useState(text ?? "")
|
||||
const [editedImages, setEditedImages] = useState(images ?? [])
|
||||
const [editedFiles, setEditedFiles] = useState(files ?? [])
|
||||
const [savingMode, setSavingMode] = useState<"chat" | "workspace" | undefined>()
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>()
|
||||
const highlightedText = useMemo(() => highlightText(text), [text])
|
||||
|
||||
const startEditing = () => {
|
||||
setEditedText(text ?? "")
|
||||
setEditedImages(images ?? [])
|
||||
setEditedFiles(files ?? [])
|
||||
setErrorMessage(undefined)
|
||||
setIsEditing(true)
|
||||
}
|
||||
@@ -56,8 +60,8 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
EditMessageAndRegenerateRequest.create({
|
||||
messageTs,
|
||||
text: editedText,
|
||||
images: images ?? [],
|
||||
files: files ?? [],
|
||||
images: editedImages,
|
||||
files: editedFiles,
|
||||
restoreWorkspace,
|
||||
}),
|
||||
)
|
||||
@@ -120,6 +124,14 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
rows={Math.max(3, editedText.split("\n").length)}
|
||||
value={editedText}
|
||||
/>
|
||||
{(editedImages.length > 0 || editedFiles.length > 0) && (
|
||||
<Thumbnails
|
||||
files={editedFiles}
|
||||
images={editedImages}
|
||||
setFiles={setEditedFiles}
|
||||
setImages={setEditedImages}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && <div className="text-xs text-(--vscode-errorForeground)">{errorMessage}</div>}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<button
|
||||
|
||||
@@ -118,4 +118,21 @@ describe("UserMessage – IME composition handling", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("removes an image before regenerating an edited message", async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UserMessage images={["image.png"]} messageTs={123} text="Update this" />)
|
||||
|
||||
await user.click(screen.getByText("Update this"))
|
||||
const thumbnail = screen.getByAltText("Thumbnail image-1")
|
||||
fireEvent.mouseEnter(thumbnail.parentElement as HTMLElement)
|
||||
const removeButton = thumbnail.parentElement?.querySelector(".codicon-close")?.parentElement
|
||||
expect(removeButton).not.toBeNull()
|
||||
await user.click(removeButton as HTMLElement)
|
||||
|
||||
expect(screen.queryByAltText("Thumbnail image-1")).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole("button", { name: "Reset Chat" }))
|
||||
await waitFor(() => expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenCalledTimes(1))
|
||||
expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenCalledWith(expect.objectContaining({ images: [] }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ function toOnboardingModel(
|
||||
|
||||
return {
|
||||
id: rec.id,
|
||||
// Names arrive display-ready from the recommended-models RPC
|
||||
name: rec.name || rec.id,
|
||||
group,
|
||||
badge,
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
recommended: [
|
||||
{
|
||||
id: "cline-next",
|
||||
name: "Cline Next",
|
||||
description: "Next Cline model",
|
||||
tags: ["recommended"],
|
||||
},
|
||||
@@ -99,7 +100,9 @@ describe("ClineModelPicker", () => {
|
||||
it("commits Cline model selections through provider config so providers.json is updated", async () => {
|
||||
render(<ClineModelPicker currentMode="act" />)
|
||||
|
||||
fireEvent.click(await screen.findByText("cline-next"))
|
||||
// Featured cards render the display name from the RPC, but selection
|
||||
// still commits the underlying model id.
|
||||
fireEvent.click(await screen.findByText("Cline Next"))
|
||||
|
||||
await waitFor(() => expect(mocks.commitSelection).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
|
||||
@@ -108,6 +111,28 @@ describe("ClineModelPicker", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("renders RPC-provided display names on featured cards, falling back to ids", async () => {
|
||||
// Names arrive display-ready: the extension host resolves them against
|
||||
// the model catalog in fetchClineRecommendedModels.
|
||||
mocks.makeUnaryRequest.mockResolvedValueOnce({
|
||||
recommended: [{ id: "anthropic/claude-opus-5", name: "Claude Opus 5", description: "Frontier model", tags: ["NEW"] }],
|
||||
free: [
|
||||
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", description: "Fast and efficient", tags: [] },
|
||||
{ id: "unknown/mystery-model", name: "", description: "No display name", tags: [] },
|
||||
],
|
||||
})
|
||||
|
||||
render(<ClineModelPicker currentMode="act" />)
|
||||
|
||||
expect(await screen.findByText("Claude Opus 5")).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText("Free"))
|
||||
|
||||
expect(await screen.findByText("DeepSeek V4 Flash")).toBeInTheDocument()
|
||||
// A missing display name degrades to the raw id
|
||||
expect(screen.getByText("unknown/mystery-model")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("hydrates the selected Cline model from provider config when legacy settings are empty", () => {
|
||||
vi.mocked(useExtensionState).mockReturnValue({
|
||||
apiConfiguration: {},
|
||||
|
||||
@@ -56,6 +56,7 @@ interface ClineModelPickerProps {
|
||||
|
||||
interface FeaturedModelCardEntry {
|
||||
id: string
|
||||
name?: string
|
||||
description: string
|
||||
label: string
|
||||
}
|
||||
@@ -67,7 +68,7 @@ function normalizeModelId(modelId: string): string {
|
||||
}
|
||||
|
||||
function toFeaturedModelCardEntry(
|
||||
model: Pick<ClineRecommendedModel, "id" | "description" | "tags">,
|
||||
model: Pick<ClineRecommendedModel, "id" | "name" | "description" | "tags">,
|
||||
fallbackLabel: string,
|
||||
): FeaturedModelCardEntry | null {
|
||||
if (!model.id) {
|
||||
@@ -79,6 +80,7 @@ function toFeaturedModelCardEntry(
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description || (fallbackLabel === "FREE" ? "Free model" : "Recommended model"),
|
||||
label: normalizedLabel || fallbackLabel,
|
||||
}
|
||||
@@ -442,10 +444,10 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
|
||||
recommendedModels.map((model) => (
|
||||
<FeaturedModelCard
|
||||
description={model.description}
|
||||
displayName={model.name || model.id}
|
||||
isSelected={selectedModelId === model.id}
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
modelId={model.id}
|
||||
onClick={() => {
|
||||
handleModelChange(model.id)
|
||||
setIsDropdownVisible(false)
|
||||
@@ -456,10 +458,10 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
|
||||
freeModels.map((model) => (
|
||||
<FeaturedModelCard
|
||||
description={model.description}
|
||||
displayName={model.name || model.id}
|
||||
isSelected={selectedModelId === model.id}
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
modelId={model.id}
|
||||
onClick={() => {
|
||||
handleModelChange(model.id)
|
||||
setIsDropdownVisible(false)
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react"
|
||||
import styled from "styled-components"
|
||||
|
||||
interface FeaturedModelCardProps {
|
||||
modelId: string
|
||||
displayName: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
isSelected: boolean
|
||||
@@ -50,11 +50,11 @@ const Description = styled.div`
|
||||
line-height: 1.2;
|
||||
`
|
||||
|
||||
const FeaturedModelCard: React.FC<FeaturedModelCardProps> = ({ modelId, description, onClick, isSelected, label }) => {
|
||||
const FeaturedModelCard: React.FC<FeaturedModelCardProps> = ({ displayName, description, onClick, isSelected, label }) => {
|
||||
return (
|
||||
<CardContainer isSelected={isSelected} onClick={onClick}>
|
||||
<ModelHeader>
|
||||
<ModelName>{modelId}</ModelName>
|
||||
<ModelName>{displayName}</ModelName>
|
||||
<Label>{label}</Label>
|
||||
</ModelHeader>
|
||||
<Description>{description}</Description>
|
||||
|
||||
@@ -137,7 +137,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
[],
|
||||
) // Empty deps - these imports never change
|
||||
|
||||
const { version, environment, settingsInitialModelTab } = useExtensionState()
|
||||
const { version, extensionVariant, environment, settingsInitialModelTab } = useExtensionState()
|
||||
const { activeOrganization, clineUser } = useClineAuth()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(targetSection || SETTINGS_TABS[0].id)
|
||||
@@ -240,12 +240,13 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
props.onResetState = handleResetState
|
||||
} else if (activeTab === "about") {
|
||||
props.version = version
|
||||
props.extensionVariant = extensionVariant
|
||||
} else if (activeTab === "api-config") {
|
||||
props.initialModelTab = settingsInitialModelTab
|
||||
}
|
||||
|
||||
return <Component {...props} />
|
||||
}, [activeTab, handleResetState, settingsInitialModelTab, version, TAB_CONTENT_MAP])
|
||||
}, [activeTab, handleResetState, settingsInitialModelTab, version, extensionVariant, TAB_CONTENT_MAP])
|
||||
|
||||
return (
|
||||
<Tab>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import type { ChangeEventHandler, ReactNode } from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { BaseUrlField } from "./BaseUrlField"
|
||||
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeCheckbox: ({
|
||||
checked,
|
||||
children,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
checked?: boolean
|
||||
children?: ReactNode
|
||||
disabled?: boolean
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>
|
||||
}) => (
|
||||
<label>
|
||||
<input checked={checked} disabled={disabled} onChange={onChange} type="checkbox" />
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
VSCodeTextField: ({
|
||||
onInput,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
onInput?: ChangeEventHandler<HTMLInputElement>
|
||||
placeholder?: string
|
||||
value?: string
|
||||
}) => <input onChange={onInput} placeholder={placeholder} value={value} />,
|
||||
}))
|
||||
|
||||
async function flushDebounce() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
})
|
||||
}
|
||||
|
||||
describe("BaseUrlField", () => {
|
||||
it("checks the box and shows the URL once the saved value loads asynchronously", () => {
|
||||
// Provider config is fetched after mount, so the saved base URL
|
||||
// arrives as an initialValue update rather than at first render.
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<BaseUrlField initialValue={undefined} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByRole("checkbox")).not.toBeChecked()
|
||||
|
||||
rerender(<BaseUrlField initialValue="https://proxy.example.com" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByRole("checkbox")).toBeChecked()
|
||||
expect(screen.getByRole("textbox")).toHaveValue("https://proxy.example.com")
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("stays unchecked after the user unchecks it, even if a stale value echoes back", () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<BaseUrlField initialValue="https://proxy.example.com" onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"))
|
||||
expect(screen.getByRole("checkbox")).not.toBeChecked()
|
||||
expect(onChange).toHaveBeenCalledWith("")
|
||||
|
||||
// A stale echo of the old config must not re-check the box.
|
||||
rerender(<BaseUrlField initialValue="https://proxy.example.com" onChange={onChange} />)
|
||||
expect(screen.getByRole("checkbox")).not.toBeChecked()
|
||||
})
|
||||
|
||||
it("restores the checked state when clearing the persisted URL fails", async () => {
|
||||
const onChange = vi.fn()
|
||||
const onClear = vi.fn().mockRejectedValue(new Error("write failed"))
|
||||
render(<BaseUrlField initialValue="https://proxy.example.com" onChange={onChange} onClear={onClear} />)
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"))
|
||||
expect(screen.getByRole("checkbox")).not.toBeChecked()
|
||||
|
||||
await act(async () => {})
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole("checkbox")).toBeChecked()
|
||||
expect(screen.getByRole("textbox")).toHaveValue("https://proxy.example.com")
|
||||
})
|
||||
|
||||
it("clears the hidden input value after persistence succeeds", async () => {
|
||||
const onChange = vi.fn()
|
||||
const onClear = vi.fn().mockResolvedValue(undefined)
|
||||
const { rerender } = render(
|
||||
<BaseUrlField initialValue="https://proxy.example.com" onChange={onChange} onClear={onClear} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"))
|
||||
await act(async () => {})
|
||||
rerender(<BaseUrlField initialValue={undefined} onChange={onChange} onClear={onClear} />)
|
||||
fireEvent.click(screen.getByRole("checkbox"))
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("")
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("cancels a pending URL edit before clearing", async () => {
|
||||
const onChange = vi.fn()
|
||||
let resolveClear: () => void = () => {}
|
||||
const onClear = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveClear = resolve
|
||||
}),
|
||||
)
|
||||
const { unmount } = render(
|
||||
<BaseUrlField initialValue="https://saved.example.com" onChange={onChange} onClear={onClear} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "https://pending.example.com" } })
|
||||
fireEvent.click(screen.getByRole("checkbox"))
|
||||
await flushDebounce()
|
||||
unmount()
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => resolveClear())
|
||||
})
|
||||
|
||||
it("saves the trimmed URL after typing", async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<BaseUrlField initialValue={undefined} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"))
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "https://proxy.example.com " } })
|
||||
|
||||
await flushDebounce()
|
||||
expect(onChange).toHaveBeenLastCalledWith("https://proxy.example.com")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDebouncedInput } from "../utils/useDebouncedInput"
|
||||
|
||||
/**
|
||||
@@ -8,6 +8,8 @@ import { useDebouncedInput } from "../utils/useDebouncedInput"
|
||||
interface BaseUrlFieldProps {
|
||||
initialValue: string | undefined
|
||||
onChange: (value: string) => void
|
||||
/** Clears the persisted value. Reject to restore the authoritative enabled state. */
|
||||
onClear?: () => Promise<void>
|
||||
defaultValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
@@ -21,27 +23,65 @@ interface BaseUrlFieldProps {
|
||||
export const BaseUrlField = ({
|
||||
initialValue,
|
||||
onChange,
|
||||
onClear,
|
||||
label = "Use custom base URL",
|
||||
placeholder = "Default: https://api.example.com",
|
||||
disabled = false,
|
||||
showLockIcon = false,
|
||||
}: BaseUrlFieldProps) => {
|
||||
const [isEnabled, setIsEnabled] = useState(!!initialValue)
|
||||
const [localValue, setLocalValue] = useDebouncedInput(initialValue || "", onChange)
|
||||
const [isClearing, setIsClearing] = useState(false)
|
||||
const userToggledRef = useRef(false)
|
||||
const [localValue, setLocalValue, syncLocalValue] = useDebouncedInput(initialValue || "", (value: string) =>
|
||||
onChange(value.trim()),
|
||||
)
|
||||
|
||||
// Provider config loads asynchronously, so a saved base URL usually arrives
|
||||
// after mount (initialValue starts undefined). Reflect it in the checkbox
|
||||
// once it lands, unless the user has already toggled the box themselves.
|
||||
useEffect(() => {
|
||||
if (!userToggledRef.current) {
|
||||
setIsEnabled(!!initialValue)
|
||||
}
|
||||
}, [initialValue])
|
||||
|
||||
const handleToggle = (e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
userToggledRef.current = true
|
||||
setIsEnabled(checked)
|
||||
if (!checked) {
|
||||
setLocalValue("")
|
||||
onChange("")
|
||||
// Cancel any pending debounced edit before starting the clear. Otherwise
|
||||
// its timer or unmount cleanup could restore the non-empty URL while the
|
||||
// clear write is in flight.
|
||||
syncLocalValue("")
|
||||
let clearResult: Promise<void> | undefined
|
||||
try {
|
||||
clearResult = onClear?.()
|
||||
} catch {
|
||||
userToggledRef.current = false
|
||||
setIsEnabled(!!initialValue)
|
||||
syncLocalValue(initialValue || "")
|
||||
return
|
||||
}
|
||||
if (clearResult) {
|
||||
setIsClearing(true)
|
||||
void clearResult
|
||||
.catch(() => {
|
||||
userToggledRef.current = false
|
||||
setIsEnabled(!!initialValue)
|
||||
syncLocalValue(initialValue || "")
|
||||
})
|
||||
.finally(() => setIsClearing(false))
|
||||
} else {
|
||||
onChange("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<VSCodeCheckbox checked={isEnabled} disabled={disabled} onChange={handleToggle}>
|
||||
<VSCodeCheckbox checked={isEnabled} disabled={disabled || isClearing} onChange={handleToggle}>
|
||||
{label}
|
||||
</VSCodeCheckbox>
|
||||
{showLockIcon && <i className="codicon codicon-lock text-(--vscode-descriptionForeground) text-sm" />}
|
||||
@@ -50,7 +90,7 @@ export const BaseUrlField = ({
|
||||
{isEnabled && (
|
||||
<VSCodeTextField
|
||||
disabled={disabled}
|
||||
onInput={(e: any) => setLocalValue(e.target.value.trim())}
|
||||
onInput={(e: any) => setLocalValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
type="text"
|
||||
|
||||
@@ -60,6 +60,14 @@ export const AnthropicProvider = ({ showModelOptions, isPopup, currentMode }: An
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
void write({ baseUrl: value }).catch((err) => console.error("Failed to update Anthropic base URL:", err))
|
||||
}
|
||||
const handleBaseUrlClear = async () => {
|
||||
try {
|
||||
await write({ baseUrl: "" })
|
||||
} catch (error) {
|
||||
console.error("Failed to clear Anthropic base URL:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
if (!modelId) {
|
||||
@@ -95,6 +103,7 @@ export const AnthropicProvider = ({ showModelOptions, isPopup, currentMode }: An
|
||||
initialValue={config?.baseUrl}
|
||||
label="Use custom base URL"
|
||||
onChange={handleBaseUrlChange}
|
||||
onClear={handleBaseUrlClear}
|
||||
placeholder="Default: https://api.anthropic.com"
|
||||
showLockIcon={!!remoteConfigSettings?.anthropicBaseUrl}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ModelInfo } from "@shared/api"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useProviderConfig } from "@/hooks/useProviderConfig"
|
||||
import { useStaticProviderSelection } from "@/hooks/useStaticProviderSelection"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { DebouncedTextField } from "../common/DebouncedTextField"
|
||||
@@ -31,6 +32,7 @@ const askSageDefaultURL = "https://api.asksage.ai/server"
|
||||
export const AskSageProvider = ({ showModelOptions, isPopup, currentMode }: AskSageProviderProps) => {
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
const { write } = useProviderConfig("asksage")
|
||||
const { models, selectedModelId, selectedModelInfo, hideUsageCost } = useStaticProviderSelection(
|
||||
"asksage",
|
||||
apiConfiguration,
|
||||
@@ -38,6 +40,15 @@ export const AskSageProvider = ({ showModelOptions, isPopup, currentMode }: AskS
|
||||
)
|
||||
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo>>(models)
|
||||
|
||||
// Write through the SDK provider-config store so providers.json stays in
|
||||
// sync for CLI/desktop hosts. The store mirrors `baseUrl` back to the
|
||||
// legacy `asksageApiUrl` state key (even when providers.json validation
|
||||
// rejects a partially-typed URL), so the legacy readers — including the
|
||||
// /get-models fetch keyed on apiConfiguration.asksageApiUrl — keep working.
|
||||
const handleApiUrlChange = (value: string) => {
|
||||
void write({ baseUrl: value }).catch((err) => console.error("Failed to update AskSage API URL:", err))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
@@ -87,7 +98,7 @@ export const AskSageProvider = ({ showModelOptions, isPopup, currentMode }: AskS
|
||||
|
||||
<DebouncedTextField
|
||||
initialValue={apiConfiguration?.asksageApiUrl || askSageDefaultURL}
|
||||
onChange={(value) => handleFieldChange("asksageApiUrl", value)}
|
||||
onChange={handleApiUrlChange}
|
||||
placeholder="Enter AskSage API URL..."
|
||||
style={{ width: "100%" }}
|
||||
type="text">
|
||||
|
||||
@@ -46,14 +46,17 @@ function clinePassFallbackModelInfo(modelId: string): ModelInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function toSubscribedEntry(model: Pick<ClineRecommendedModel, "id" | "description">): FeaturedTabEntry | null {
|
||||
// Names arrive display-ready from the recommended-models RPC (the extension
|
||||
// host resolves them against the model catalog in fetchClineRecommendedModels)
|
||||
|
||||
function toSubscribedEntry(model: Pick<ClineRecommendedModel, "id" | "name" | "description">): FeaturedTabEntry | null {
|
||||
if (!model.id) {
|
||||
return null
|
||||
}
|
||||
// The whole list is included with the plan, so no per-card label chip
|
||||
return {
|
||||
id: model.id,
|
||||
displayName: model.id.replace(CLINE_PASS_MODEL_ID_PREFIX, ""),
|
||||
displayName: model.name || model.id.replace(CLINE_PASS_MODEL_ID_PREFIX, ""),
|
||||
description: model.description || "",
|
||||
label: "",
|
||||
}
|
||||
@@ -66,8 +69,7 @@ function toFreeEntry(model: Pick<ClineRecommendedModel, "id" | "name" | "descrip
|
||||
const firstTag = model.tags?.[0]
|
||||
return {
|
||||
id: model.id,
|
||||
// The FREE chip already says it, so drop OpenRouter's :free marker
|
||||
displayName: (model.name || model.id).replace(/:free$/i, ""),
|
||||
displayName: model.name || model.id,
|
||||
description: model.description || "",
|
||||
label: typeof firstTag === "string" && firstTag.length > 0 ? firstTag.toUpperCase() : "FREE",
|
||||
}
|
||||
@@ -94,8 +96,8 @@ export const ClinePassProvider = ({ showModelOptions, isPopup, currentMode }: Cl
|
||||
customModelInfo: clinePassFallbackModelInfo,
|
||||
})
|
||||
const { clineUser } = useClineAuth()
|
||||
const [subscribedEntries, setSubscribedEntries] = useState<FeaturedTabEntry[]>([])
|
||||
const [freeEntries, setFreeEntries] = useState<FeaturedTabEntry[]>([])
|
||||
const [subscribedModels, setSubscribedModels] = useState<ClineRecommendedModel[]>([])
|
||||
const [freeModels, setFreeModels] = useState<ClineRecommendedModel[]>([])
|
||||
const [activeTab, setActiveTab] = useState<"subscribed" | "free">("subscribed")
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,14 +113,8 @@ export const ClinePassProvider = ({ showModelOptions, isPopup, currentMode }: Cl
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setSubscribedEntries(
|
||||
(response.clinePass ?? [])
|
||||
.map(toSubscribedEntry)
|
||||
.filter((entry): entry is FeaturedTabEntry => entry !== null),
|
||||
)
|
||||
setFreeEntries(
|
||||
(response.free ?? []).map(toFreeEntry).filter((entry): entry is FeaturedTabEntry => entry !== null),
|
||||
)
|
||||
setSubscribedModels(response.clinePass ?? [])
|
||||
setFreeModels(response.free ?? [])
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh ClinePass recommended models:", err)
|
||||
}
|
||||
@@ -132,23 +128,19 @@ export const ClinePassProvider = ({ showModelOptions, isPopup, currentMode }: Cl
|
||||
// Fall back to the provider catalog (subscribed) and the bundled free list
|
||||
// until the endpoint responds
|
||||
const subscribedCards = useMemo(() => {
|
||||
if (subscribedEntries.length > 0) {
|
||||
return subscribedEntries
|
||||
if (subscribedModels.length > 0) {
|
||||
return subscribedModels.map(toSubscribedEntry).filter((entry): entry is FeaturedTabEntry => entry !== null)
|
||||
}
|
||||
return Object.keys(models ?? {})
|
||||
.filter((id) => id.startsWith(CLINE_PASS_MODEL_ID_PREFIX))
|
||||
.map((id) => toSubscribedEntry({ id, description: models[id]?.description ?? "" }))
|
||||
.map((id) => toSubscribedEntry({ id, name: models[id]?.name ?? "", description: models[id]?.description ?? "" }))
|
||||
.filter((entry): entry is FeaturedTabEntry => entry !== null)
|
||||
}, [subscribedEntries, models])
|
||||
}, [subscribedModels, models])
|
||||
|
||||
const freeCards = useMemo(() => {
|
||||
if (freeEntries.length > 0) {
|
||||
return freeEntries
|
||||
}
|
||||
return CLINE_RECOMMENDED_MODELS_FALLBACK.free
|
||||
.map(toFreeEntry)
|
||||
.filter((entry): entry is FeaturedTabEntry => entry !== null)
|
||||
}, [freeEntries])
|
||||
const source = freeModels.length > 0 ? freeModels : CLINE_RECOMMENDED_MODELS_FALLBACK.free
|
||||
return source.map(toFreeEntry).filter((entry): entry is FeaturedTabEntry => entry !== null)
|
||||
}, [freeModels])
|
||||
|
||||
// Land on the tab containing the configured model
|
||||
useEffect(() => {
|
||||
@@ -201,10 +193,10 @@ export const ClinePassProvider = ({ showModelOptions, isPopup, currentMode }: Cl
|
||||
{activeCards.map((entry) => (
|
||||
<FeaturedModelCard
|
||||
description={entry.description}
|
||||
displayName={entry.displayName}
|
||||
isSelected={selectedModel.modelId === entry.id}
|
||||
key={entry.id}
|
||||
label={entry.label}
|
||||
modelId={entry.displayName}
|
||||
onClick={() => handleFeaturedModelSelect(entry.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -65,6 +65,14 @@ export const GenericProviderSettings = ({
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
void write({ baseUrl: value }).catch((err) => console.error(`Failed to update ${providerName} base URL:`, err))
|
||||
}
|
||||
const handleBaseUrlClear = async () => {
|
||||
try {
|
||||
await write({ baseUrl: "" })
|
||||
} catch (error) {
|
||||
console.error(`Failed to clear ${providerName} base URL:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -81,6 +89,7 @@ export const GenericProviderSettings = ({
|
||||
initialValue={config?.baseUrl}
|
||||
label={baseUrlField.label}
|
||||
onChange={handleBaseUrlChange}
|
||||
onClear={handleBaseUrlClear}
|
||||
placeholder={baseUrlField.placeholder}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -82,6 +82,14 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => {
|
||||
},
|
||||
[write],
|
||||
)
|
||||
const handleBaseUrlClear = useCallback(async () => {
|
||||
try {
|
||||
await write({ baseUrl: "" })
|
||||
} catch (error) {
|
||||
console.error("Failed to clear LM Studio base URL:", error)
|
||||
throw error
|
||||
}
|
||||
}, [write])
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(modelId: string) => {
|
||||
@@ -154,6 +162,7 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => {
|
||||
initialValue={config?.baseUrl ?? apiConfiguration?.lmStudioBaseUrl}
|
||||
label="Use custom base URL"
|
||||
onChange={handleBaseUrlChange}
|
||||
onClear={handleBaseUrlClear}
|
||||
placeholder="Default: http://localhost:1234"
|
||||
/>
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
|
||||
)
|
||||
const { savedApiKeyMask, handleApiKeyChange } = useProviderApiKeyField({
|
||||
apiKeyLength: config?.apiKeyLength,
|
||||
canWrite: config !== undefined,
|
||||
providerName: "LiteLLM",
|
||||
write,
|
||||
})
|
||||
@@ -64,11 +63,11 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// Writes are safe before the initial config read resolves: write() does not
|
||||
// depend on loaded config, and useProviderConfig drops the stale read
|
||||
// response. Gating on `config` here would silently discard text typed right
|
||||
// after the settings view mounts.
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
void write({ baseUrl: value }).catch((err) => console.error("Failed to update LiteLLM base URL:", err))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user