Compare commits

...
Author SHA1 Message Date
abeatrix 656e170d11 custom bg 2026-07-17 17:22:37 -07:00
abeatrix 8d9f63b06e pet overlay 2026-07-17 16:59:14 -07:00
abeatrix dc44e00fe8 poc(desktop): pet 2026-07-17 16:26:37 -07:00
abeatrix 677bf62a09 home page update 2026-07-17 14:47:32 -07:00
abeatrix faa96c5d1e Merge remote-tracking branch 'origin/main' into bee/filter-paths
# Conflicts:
#	apps/examples/desktop-app/sidecar/commands.ts
2026-07-16 20:01:21 -07:00
Saoud Rizwan 9a5e1751b2 chore(cli): release v3.0.44 2026-07-16 18:38:54 -07:00
Saoud Rizwan 131e25e1a1 chore(sdk): release v0.0.64 2026-07-16 18:14:58 -07:00
Saoud Rizwan a7ff007af9 chore(cli): release v3.0.43 2026-07-16 17:52:46 -07:00
ef27f45080 fix: max output token handling (#12031)
* fix: max output token handling

* shared

* max reasoning budgetTokens

* fix unit test

* fix: address review feedback on max output token handling

- OpenRouter effort branch sends only reasoning.effort (OpenRouter rejects
  effort combined with reasoning.max_tokens)
- OpenAI Responses forwards explicit caller maxTokens for API-key usage;
  ChatGPT OAuth and synthesized gateway defaults are still omitted
- Gateway lifts the synthesized default output cap above explicit Anthropic
  reasoning budgets so max_tokens > thinking.budget_tokens holds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address second-round review feedback on max output token handling

- Replace the gateway-only requestedMaxTokens field with a defaultedMaxTokens
  flag set when the gateway synthesizes a cap, so explicit maxTokens from
  direct provider callers is forwarded by default (greptile P1)
- Check the parsed hostname instead of a URL substring when detecting the
  ChatGPT OAuth backend (CodeQL)
- Drop the empty else-if branch in toAiSdkMessages in favor of an explicit
  emptiedByDroppedReasoning condition (greptile P2; biome rejects the
  suggested bare continue)
- Dedupe isPositiveFiniteNumber by exporting it from gateway.ts (greptile P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: extract isPositiveFiniteNumber into providers/utils.ts

Move the shared helper to its own module as suggested in review instead
of exporting it from gateway.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove unrelated VS Code changes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 17:50:22 -07:00
Dominic Cooney e3c6d51072 fix: recognize frontmatter with a leading UTF-8 BOM (#12277)
* fix(vscode): recognize SKILL.md frontmatter with a leading UTF-8 BOM

SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's 'UTF-8 with BOM' encoding) were silently skipped and not recognized as skills, because gray-matter/regex-style frontmatter parsers require '---' at byte offset 0 and never accounted for the leading BOM byte sequence Node's utf-8 decoder does not strip.

Fixes the shared parseYamlFrontmatter() helper (used by skills, rules, workflows, and remote skill entries in the VS Code extension) and every duplicated ad-hoc frontmatter regex across the SDK/CLI/hub/desktop-app/example-plugin code paths to strip a leading BOM before matching.

Adds regression tests exercising the exact reported scenario (BOM-prefixed SKILL.md silently missing name/description) in frontmatter.test.ts, skills.test.ts, skill-frontmatter-toggle.test.ts, user-instruction-config-loader.test.ts, and configured-agent-config.test.ts.

Fixes https://github.com/cline/cline/issues/12151

* refactor(shared): centralize UTF-8 BOM stripping

* refactor(shared): add UTF-8 file readers

* docs: guide UTF-8 configuration reads
2026-07-17 09:37:20 +09:00
Saoud Rizwan 48bac25548 chore(sdk): regenerate lockfile for v0.0.63 2026-07-16 17:13:44 -07:00
Saoud Rizwan 37f5f104f3 chore(sdk): release v0.0.63 2026-07-16 16:47:40 -07:00
Saoud Rizwan 3577b52404 feat(core): emit mistake-limit telemetry from the session runtime (#12355)
Moves the task.mistake_limit_reached capture (#12354) from the VS Code
SdkController wrapper into @cline/core so every host (CLI, VS Code,
hub daemon) emits it via its session telemetry service.

The MistakeTracker gains an onLimitTelemetry hook fired exactly once
per limit hit, before the limit decision is resolved — including when
no onConsecutiveMistakeLimitReached callback is configured (the
default-stop path, which the extension-side capture missed). The
orchestrator wires the hook to captureMistakeLimitReached using its
reserved telemetry field, reading sessionId/modelId/providerId at fire
time so mid-session connection updates are reflected.

The now-redundant extension wrapper and TelemetryService method are
removed to avoid double-counting in VS Code.
2026-07-16 16:39:50 -07:00
1843bc8ed0 fix(vscode): persist selected account organization (#12345)
* fix(vscode): persist selected account organization

* fix(vscode): ignore stale organization responses

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:08:07 -07:00
fead00ec57 fix(vscode): avoid duplicate OpenAI provider settings (#12346)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:07:48 -07:00
238107d21c fix(vscode): preview auto-approved apply patches (#12349)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:05:24 -07:00
Saoud Rizwan 2063a661bd feat(vscode): capture telemetry when consecutive mistake limit is reached (#12354) 2026-07-16 15:58:21 -07:00
Dominic Cooney ec02d5862e Fix debug harness: run under node, drop ws dependency. (#12319)
The harness rotted after the npm-to-bun migration: the 'ws' package it imported is no longer in the dependency tree, and Playwright's _electron.launch() times out under bun (the debugee Electron starts but Playwright never finishes attaching; the same launch attaches in under a second under node). Use the runtime's built-in WebSocket for the CDP client and document that the harness must be run with node.
2026-07-17 07:43:32 +09:00
abeatrix 4d86b283dc dedup normalizeWorkspacePath 2026-07-16 14:03:18 -07:00
abeatrix adf029e8bd feat(desktop-app): add account context and window title utilities
- Add AccountContext provider and hooks for managing Cline account identity
- Add account-context.tsx and account-context.test.tsx
- Add desktop-window-title.ts and desktop-window-title.test.ts
- Update workspace-paths.ts with new utility functions
- Update agent-sidebar.tsx and agent-sidebar.test.tsx to use account context
- Update page.tsx to integrate account context
- Update sidecar/commands.ts to support account operations
- Update core SDK exports

This adds proper account identity management and window title utilities for the desktop app.
2026-07-16 13:50:02 -07:00
abeatrix c85a0be86a fix(desktop): filter project paths
Best effort to remove desktop and user's home directory from showing up in project list in the desktop app.
2026-07-16 12:42:55 -07:00
Mikołaj Kondratek 8452084842 fix: auto-discover OS trust anchors in the CLI wrapper (#11498)
* fix: auto-discover OS trust anchors in the CLI wrapper

The 3.x CLI ships as a Bun-compiled binary. Bun does not read the OS
trust store unless NODE_USE_SYSTEM_CA is set, and even with the flag its
Windows enumeration covers only the `Root` store, not `CA`/Intermediate
(verified empirically across the CLINE-2353 Windows repro rounds). So a
corporate MITM root is not trusted out of the box and inference fails
with "unable to get local issuer certificate". The pre-3.0 (Node) CLI
had no app-level CA handling either; users only succeeded by setting
NODE_EXTRA_CA_CERTS manually. The reporter's ask: have it just work
without the env var.

This follows the CLINE-2353 SDK fetch-threading change. That made the
inference client honor a host-provided proxy/CA-aware fetch, but on the
CLI Bun's global fetch is already proxy-aware and a fetch function
cannot cross the hub-daemon process boundary, so the CLI's missing piece
is trust material, not the fetch. Env vars do inherit across spawns.

The npm `bin/cline` wrapper runs on Node (not Bun), so it can read the
full OS store via tls.getCACertificates("system") (Node >= 22, no flag
required) — including the Windows `CA` store Bun skips — and hand the
certs to the Bun child via NODE_EXTRA_CA_CERTS, which both runtimes
honor. This mirrors the JetBrains plugin's configureCertificates(),
replacing "harvest from the IDE trust store" with "harvest from the OS".

The merge logic lives in a dependency-free, injectable-module CommonJS
helper (bin/ca-certs.cjs) so it is unit-testable and ships verbatim in
the generated wrapper package (publish copies bin/ wholesale). A
user-set NODE_EXTRA_CA_CERTS is merged ahead of the system certs; a
self-reference to the managed bundle is detected to avoid re-appending
every launch; when no system certs are available the user's setting is
left untouched. Writes are atomic (temp + rename) and owner-only.

Adds ca-certs.test.ts (13 cases) covering harvest filtering, user-bundle
PEM/DER/missing handling, newline-separated merge, managed-path
self-reference, and the no-system-certs no-op.

* fix: harden CLI auto-CA harvesting (review follow-ups)

Follow-ups from the CLINE-2353 review of the CLI auto-CA wrapper.

- H1: a legacy NODE_EXTRA_CA_CERTS set to an OS-path-delimited list
  ("a.pem;b.pem", the CLINE-2324 footgun Node never split) was stat'd as
  one file, failed, and silently dropped the user's certs. readUserCerts
  now tries the whole value as one file first, then splits on the OS path
  delimiter and reads each existing PEM, merging them all.
- M1: skip the rewrite when the managed bundle is already current, instead
  of re-harvesting and rewriting on every launch (mirrors the JetBrains
  hash-and-skip). configureNodeExtraCaCerts now returns a typed outcome
  (unchanged | written | write-failed-reused | write-failed |
  no-system-certs) with cert counts.
- M2: tolerate rename-over-existing failures (Windows EPERM/EBUSY when a
  concurrent child holds the file open) by removing the target and
  retrying, then falling back to a previously-written bundle. Combined
  with M1 the steady state no longer rewrites at all.
- M3: the wrapper prints a one-line diagnostic under CLINE_DEBUG=1
  (cert counts + managed path, or a warning when no OS certs were found
  or the write failed). Runs once per startup.
- M4: corrected the now-stale CLI guidance in shared/net.ts (the CLI no
  longer requires users to set NODE_EXTRA_CA_CERTS manually).
- L1: documented the auto-trust behavior, the managed ~/.cline bundle,
  the merge-not-replace override semantics, and CLINE_DEBUG in the CLI
  README.
- L4: trimmed the helper's file header; DI is still injectable for tests.

ca-certs.test.ts grows to 20 cases: adds readUserCerts (single path,
delimited split, missing-segment skip, managed-bundle exclusion, empty),
the unchanged/second-run skip, and a write-failure outcome via an
fs that throws.

* fix: address CLI auto-CA review issues (temp cleanup, cert count, test)

- writeBundle now hoists the temp path so the outer catch removes a
  partially-written temp file (e.g. ENOSPC / ACL failure mid-write).
  Previously only the inner double-rename failure cleaned up, so repeated
  disk-full/permission failures left a stale .tmp per launch in ~/.cline.
  The inner Windows-rename fallback now lets its failure fall through to
  the single cleanup path instead of duplicating rmSync.
- userCertCount now counts individual certificates (via countCerts, which
  tallies BEGIN CERTIFICATE markers) rather than the number of PEM files,
  so a user bundle with N intermediates reports N and is comparable to
  systemCertCount. countCerts is exported for testing.
- Adds tests for the write-failed-reused branch (stale bundle reused when
  the rewrite fails but the old file is still readable) and for countCerts
  (one file holding two certs reports 2).

* fix: warn when the CLI wrapper's Node cannot read the OS trust store

tls.getCACertificates("system") needs Node >= 22.15; on older hosts the
auto-CA harvest silently did nothing, which is indistinguishable from a
broken corporate proxy. Distinguish the missing-API case as its own
outcome (api-unavailable) and print a non-debug warning when the user
has no NODE_EXTRA_CA_CERTS of their own. Found in round-5 Windows
validation (wrapper under Node 22.1.0).

* fix: copy only certificate blocks into the managed CA bundle

Combined cert+key PEMs (nginx/haproxy-style server.pem) passed the
old contains-a-certificate check, so a user NODE_EXTRA_CA_CERTS
pointing at one duplicated the private key into the managed bundle,
where it outlives rotation of the original and gets no permission
tightening on Windows. Extract complete BEGIN/END CERTIFICATE blocks
instead; files with none are treated as not PEM, and certificates-only
files pass through byte-identical so the unchanged-skip stays stable.
Raised in PR review.

* fix: show the old-Node trust warning once per Node version

The api-unavailable warning printed on every CLI invocation, turning
an actionable nudge into stderr noise for users pinned to an old Node.
Stamp the warning per Node version under the cline dir: it shows once,
re-arms when the Node version changes, and a bookkeeping failure never
suppresses the diagnostic. Raised in PR review.
2026-07-16 10:14:15 -07:00
Dominic Cooney a41129a5db fix(vscode): restore 'Proceed While Running' for foreground terminal commands (#12320)
* First cut of 'proceed while running' for foreground tasks.

* Address review: flush partial line on detach; cap log before write; freeze partial output at detach.

* fix(vscode): cap detached command log replay
2026-07-15 22:46:37 -07:00
Saoud Rizwan 1ea34be611 chore(cli): release v3.0.42 2026-07-15 20:03:31 -07:00
Saoud Rizwan e72bc3cd14 chore(sdk): release v0.0.62 2026-07-15 19:46:58 -07:00
9c907af826 Send the Feature Flag Event when rolling out (#12325)
* Send the Feature Flag Event when rolling out

* Update apps/vscode-rollout/scripts/smoke-loader.mjs

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 19:39:37 -07:00
Saoud Rizwan e8d3d82522 fix(core): omit telemetry from hub tool contexts (#12326) 2026-07-15 19:33:12 -07:00
7f9d2e96d9 fix(ollama): restore native API routing so context window and timeout settings work (#12286)
The 4.0.0 SDK migration routed Ollama through the generic OpenAI-compatible
vendor (/v1/chat/completions), which cannot express Ollama's options.num_ctx.
Every model loaded at Ollama's 4096-token server default, truncating Cline's
prompt and breaking most features (CLINE-2603, CLINE-2566, CLINE-2572).

- Add a native Ollama vendor backed by ai-sdk-ollama (wraps the official
  ollama client); num_ctx derives from the resolved gateway model's
  contextWindow at the adapter boundary, defaulting to 32768
- Persist the Model Context Window setting in providers.json via the
  pre-existing provider-neutral contextWindow field (legacy
  ollamaApiOptionsCtxNum state key kept as read fallback / write mirror),
  and surface it as the selected model's contextWindow so the chat
  indicator, compaction budgets, and num_ctx all agree
- Project ProviderConfig.maxInputTokens (where ProviderSettings.contextWindow
  lands) onto the selected gateway model in both gateway builders so
  CLI/Core hosts honor the configured value too
- Stop falling back to the bundled Ollama-Cloud catalog when /api/tags is
  empty; local-model-source providers keep the user's committed model
  instead of silently selecting a cloud model (nemotron)
- Wire Request Timeout (ms) with the legacy semantics (response must start
  within requestTimeoutMs || 30000; streaming never cut off mid-generation)
- Settings UI: gate the context-window field until provider config loads,
  skip unchanged writes, drop the custom prompt checkbox

Fixes CLINE-2603, CLINE-2566, CLINE-2572

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 17:55:29 -07:00
Saoud Rizwan d618f8073a fix(vscode-rollout): align bundle versions and harden combined publish workflows (#12321)
* fix(vscode-rollout): align bundle versions in the stable AB workflow

Found by Max in local testing: the union manifest's version (what the
Marketplace and auto-update see) is the stitch input, but each bundle's
About tab and telemetry extension_version read that bundle's OWN
package.json — so the stable combined VSIX reported three different
versions (dispatch input / main's 4.0.0 / legacy's 4.0.8) depending on
where you looked. The nightly channel doesn't have this problem
(nightlify.mjs stamps one version into everything); this gives the stable
channel the identity-preserving equivalent: scripts/set-version.mjs stamps
the dispatch version into each checkout after install, before its build.

Also fixes a latent ab-package bug while restructuring the steps: the
next-bundle build never ran build:sdk, so the @cline/* workspace deps had
no dist and esbuild would fail on a fresh CI checkout (the workflow has
never run end-to-end — the publish environment gate blocked pre-merge
dispatches). Split install/build:sdk/align/build into separate steps,
mirroring the nightly workflow.

* fix(vscode-rollout): assert bundle sub-manifest versions in identity guardrails

Greptile round on #12321: the stable guardrail didn't assert version at
all. Went one further than the suggestion — both workflows' guardrails now
also assert each bundle sub-manifest's version (and name, for nightly)
matches the expected version, which is the check that actually regression-
guards the set-version.mjs/nightlify.mjs stamping (About tab + telemetry
extension_version read the sub-manifests, not the union). Expected version
routed through env rather than interpolated into the script body. Adds the
conventional paired test for set-version.mjs.

* fix(vscode-rollout): don't fail the nightly run when the tag push is rejected

First real combined publish (run 29454994164) published to both registries
successfully but the run went red at the last step: the default
GITHUB_TOKEN cannot create a ref whose commit modifies workflow files, and
HEAD was the #12253 squash merge which rewrote this very workflow. There
is no workflows permission grantable to the token, so this recurs any
night HEAD touched .github/workflows. The tag is bookkeeping — mark the
step continue-on-error so a successful publish isn't reported as a
failure. (Today's missing tag was pushed manually.)
2026-07-15 17:39:44 -07:00
Saoud Rizwan eb21ba583c fix(ci): restrict nightly publishing to main (#12322) 2026-07-15 15:35:09 -07:00
f29c25395c feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout (#12253)
* feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout

Ship one marketplace VSIX containing a tiny loader plus two complete
extension bundles: next/ (SDK-based apps/vscode from main) and legacy/
(the legacy-extension branch). The loader picks one bundle per window
from a PostHog-flag-driven, sticky, one-way cohort assignment, activates
it with a Proxy-scoped ExtensionContext so each bundle resolves its
resources from its own subdirectory, and falls back to legacy (with
partial-registration cleanup and version pinning) if the next bundle
crashes during activation.

Includes the union-manifest generator with per-cohort when-clause
gating, the VSIX stitcher, a node-level loader smoke test, and the
ext-vscode-ab-package workflow that builds both refs and packages
(optionally publishes) the combined VSIX.

* fix(vscode-rollout): address rollout review feedback

* feat(vscode-rollout): versioned kill-switch, user-setting override, launch-cadence telemetry

Review follow-ups from #12253:

- Kill-switch is now scoped by version instead of boolean: the PostHog flag's
  payload carries {"maxKilledVersion": "x.y.z"} and the loader demotes only
  combined VSIXes <= that version, so killing a broken release never blocks
  the release that fixes it. Arming with no payload still demotes everything,
  and the old boolean memento format is normalized on read.

- cline.rollout.bundleOverride user setting (auto | next | legacy) as a
  manual escape hatch editable straight from settings.json: beats flags and
  the kill-switch in both directions, applies on window reload, reported as
  'override' on the activation event. Injected into the union manifest by
  gen-manifest so neither bundle has to know about it.

- parseRolloutFlags hardens flag typing: only a literal boolean true promotes
  (multivariate variants, numbers, junk fail safe), kill payloads are parsed
  defensively from /decide's JSON-string encoding.

- Activation events now carry ms_since_last_activation so the real window-
  reload cadence bounds how fast the rollout percentage gets dialed up.

- Walkthrough manifest invariant relaxed from byte-equality to structural
  equality (ids/media/completionEvents): the branches already diverge on one
  MCP step description, and since walkthrough markdown at the VSIX root comes
  from next regardless, hard-failing on copy tweaks bricked the release
  pipeline while protecting nothing. Copy divergence now warns and ships
  next's text.

* feat(vscode-rollout): identity-aware namespace, authoritative activation telemetry, nightly indicator

- Derive the setting section and sdkBundle context key from the packaged
  manifest name (cline.* for stable claude-dev, cline-nightly.* for the
  nightly identity, whose packaging rewrites the whole ID namespace);
  gen-manifest derives the same prefix for gates and the injected
  bundleOverride setting.
- Call the activated bundle's reportRolloutActivation export (merged on
  both branches) with attempted/actual/fallback — the authoritative
  extension.rollout.bundle_activated event, attributed via the bundle's
  variant-built telemetry. On crash fallback the LEGACY bundle reports it.
- Rename the loader's direct PostHog event to
  extension.rollout.loader_decision: it collided byte-for-byte with the
  bundles' event name under a different schema. It keeps the loader-side
  metadata (override, launch cadence, loader_version, extension_name) and
  gains double_failure for the both-bundles-dead case.
- Fix duplicate activation events on crash fallback: the recursive legacy
  activation no longer emits a second, contradictory fallback:false event.
- Nightly-only status bar indicator (Cline: Next / Cline: Legacy) so
  dogfooders can see which bundle a window is running.
- Union diverged engines to the newer requirement instead of hard-failing:
  main's VS Code engine (^1.101.0) has legitimately moved ahead of
  legacy-extension's (^1.84.0), which bricked every combined build.
- Smoke scenarios for all of the above.

* feat(vscode-rollout): publish the nightly as the combined A/B VSIX

Convert ext-vscode-publish-nightly.yml (cron + dispatch) from the
standalone SDK build to the combined loader + next + legacy package,
published as saoudrizwan.cline-nightly at <major>.<minor>.<unix-seconds>:

- scripts/nightlify.mjs reproduces publish-nightly.mjs's identity mutation
  (claude-dev -> cline-nightly, "cline. -> "cline-nightly., displayName,
  activity bar title) with the version as an explicit argument so ONE
  version reaches both bundle manifests and the union manifest. Runs after
  dependency install and before each bundle build.
- Both bundle builds get CLINE_ROLLOUT_VARIANT (next/legacy) in the nightly
  AND stable workflows — without it the merged rollout telemetry
  (extension_variant common prop + the authoritative bundle_activated
  capture) silently no-ops.
- dry-run dispatch input builds and uploads the installable .vsix without
  publishing or tagging; publish/tag steps are additionally gated to main,
  so the PR branch can be dispatched for pre-merge verification.
- Identity guardrails before packaging: nightly workflow asserts
  cline-nightly, the stable ab-package workflow asserts claude-dev.
- The nightly tag now records the legacy bundle sha in its message.
- README: nightly channel section (identity mapping, the two telemetry
  events and their owners, dry-run verification), and a note that the
  PostHog flags govern nightly only until the stable combined VSIX ships.

The single-bundle publish-nightly.mjs path remains for manual
feature-branch pre-release publishes; CI no longer invokes it.

* chore(vscode-rollout): harden nightly workflow gating

- Restore a job-level branch allowlist on the publish job (main + the
  rehearsal branch). Advisory defense-in-depth: the enforced gate is the
  PublishNightly environment's deployment-branch policy in repo settings,
  which must list the same branches; a dispatched branch runs its own copy
  of this file.
- Route the legacy-ref dispatch input through env instead of interpolating
  it into the run script body (script-injection hygiene; dispatch already
  requires write access).

* add otel vars to rollout build (#12316)

- Extension will not emit otel metrics to otel without these vars, so
adding those into the slow-rollout build workflow

Co-authored-by: Max Paulus 🥪 <max@cline.bot>

* fix(vscode-rollout): pass OTel env to the nightly legacy bundle build

Legacy's esbuild inlines OTEL_* at build time and its standalone publish
workflow passes them, so the combined nightly's legacy bundle was being
built with the OTel logs/metrics pipeline dead. Companion to #12316,
which fixes the same gap in ext-vscode-ab-package.yml (both bundles
there).

* feat(vscode-rollout): make the rollout two-way, remove the kill-switch

The one-way cohort + versioned kill-switch existed to avoid demoting users
whose SDK-bundle tasks aren't listed by legacy and whose rotated creds may
need a re-login. Decision: those are acceptable, temporary UX costs on an
emergency-only path — not worth a second flag and permanent mechanism
complexity (payload parsing, version scoping, killed-up-to cache format).

Now there is ONE knob: each background refresh caches exactly what
ext-sdk-bundle-rollout says for the next window. Dialing the percentage
down demotes; 0% pulls everyone back to legacy on their next reload.
Fail-safe direction preserved: only a literal boolean true promotes —
variant strings / numbers / a deleted flag all resolve to legacy; malformed
/decide responses leave the cache untouched. Local crash pinning (next
threw -> pin this version to legacy on this machine) is unchanged and
independent of the flag.

Removes KILLSWITCH_FLAG/KILLSWITCH_STATE_KEY/isVersionKilled/
normalizeKilledUpTo/compareVersions/nextCachedBundle; parseRolloutFlags
becomes parseRolloutAssignment returning the bundle to cache. Smoke
scenarios replaced with two-way promote/demote coverage.

---------

Co-authored-by: Max <maxpaulus43@gmail.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 15:07:45 -07:00
MaxandMax Paulus 🥪 84c9b587a6 refactor(vscode): resolve model metadata host-side (#12130)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 13:37:02 -07:00
Saoud Rizwan 6dca234d8e chore(cli): release v3.0.41 2026-07-15 11:02:17 -07:00
Renee Huang 9217eacbbd fix: update broken ACP editor integrations redirect to CLI reference (#12312)
* fix: update broken ACP editor integrations redirect to point to CLI reference

* feat: add ACP Editor Integrations page under CLI section

- Create cli/acp-editor-integrations.mdx with ACP overview, supported editors, quick start, and usage guide
- Add page to CLI navigation group in docs.json
- Restore redirect from /cline-cli/acp-editor-integrations to /cli/acp-editor-integrations (page now exists)

* Revert "feat: add ACP Editor Integrations page under CLI section"

This reverts commit 2728b9c2ad.
2026-07-15 10:57:59 -07:00
204 changed files with 12961 additions and 1111 deletions
+3 -2
View File
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
+1
View File
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
+205
View File
@@ -0,0 +1,205 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
package:
name: Build combined (legacy + next) VSIX
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
path: next-src
lfs: true
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
+211 -47
View File
@@ -1,17 +1,40 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -20,7 +43,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
if: github.repository == 'cline/cline'
permissions:
contents: read
pull-requests: read
@@ -30,60 +53,79 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Show build source
working-directory: ${{ github.workspace }}
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
run: |
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
working-directory: next-src
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -93,20 +135,24 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Publish Nightly Extension
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -114,12 +160,129 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
- name: Tag published commit
working-directory: ${{ github.workspace }}
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -127,10 +290,11 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+23
View File
@@ -1,5 +1,28 @@
# Cline CLI Changelog
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
+15
View File
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
+281
View File
@@ -0,0 +1,281 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
+42
View File
@@ -23,6 +23,48 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.40",
"version": "3.0.44",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+367
View File
@@ -0,0 +1,367 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+3 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,6 +15,7 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+2 -1
View File
@@ -27,6 +27,7 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -18,6 +18,7 @@ import {
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
+38 -3
View File
@@ -6,6 +6,7 @@ import {
rmSync,
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, extname, join } from "node:path";
import type {
ClineAccountActionRequest,
@@ -34,6 +35,7 @@ import {
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveLocalClineAuthToken,
resolvePluginConfigSearchPaths,
@@ -50,6 +52,8 @@ import {
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -184,6 +188,31 @@ function removePathIfExists(
return true;
}
// Cline access tokens expire between app launches, so account requests must
// resolve through the refresh-aware OAuth manager instead of reading the
// persisted token directly. A single shared instance keeps concurrent account
// requests single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
async function resolveFreshClineAuthToken(
manager: ProviderSettingsManager,
): Promise<string | undefined> {
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch {
// Fall back to the persisted token; the account request surfaces the
// auth failure to the caller.
}
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
}
async function listSessionsFromSidecarManager(
ctx: SidecarContext,
limit: number,
@@ -558,7 +587,7 @@ async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -756,7 +785,13 @@ export async function handleCommand(
// ── Process context ───────────────────────────────────────────────
if (command === "get_process_context") {
return { workspaceRoot: ctx.workspaceRoot, cwd: ctx.workspaceRoot };
return {
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
homeDir: homedir(),
platform: process.platform,
appVersion: packageJson.version,
};
}
if (command === "get_chat_ws_endpoint") {
return "";
@@ -953,7 +988,7 @@ export async function handleCommand(
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
getAuthToken: async () => resolveFreshClineAuthToken(manager),
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -9,7 +9,7 @@ tauri-build = { version = "2.0.0", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11.1", features = [] }
tauri = { version = "2.11.1", features = ["macos-private-api", "tray-icon"] }
rfd = "0.15"
[features]
+106 -2
View File
@@ -8,7 +8,9 @@ use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::{Manager, RunEvent, State};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{Manager, RunEvent, State, WindowEvent};
#[derive(Clone)]
struct AppContext {
@@ -473,6 +475,51 @@ fn open_mcp_settings_file() -> Result<String, String> {
Ok(settings_path.to_string_lossy().to_string())
}
/// Begin an OS-level drag of the calling window (used by the floating pet so it
/// can be dragged anywhere on screen without window decorations).
#[tauri::command]
fn start_pet_drag(window: tauri::WebviewWindow) -> Result<(), String> {
window.start_dragging().map_err(|e| e.to_string())
}
/// Show the floating pet window and (re)assert its always-on-top / all-Spaces
/// presence so it floats above other apps even when the main window is hidden.
#[tauri::command]
fn show_pet(app: tauri::AppHandle) -> Result<(), String> {
if let Some(pet) = app.get_webview_window("pet") {
pet.show().map_err(|e| e.to_string())?;
let _ = pet.set_always_on_top(true);
let _ = pet.set_visible_on_all_workspaces(true);
}
Ok(())
}
/// Hide the floating pet window (its dismiss button and the settings toggle).
#[tauri::command]
fn hide_pet(app: tauri::AppHandle) -> Result<(), String> {
if let Some(pet) = app.get_webview_window("pet") {
pet.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
fn is_pet_visible(app: tauri::AppHandle) -> bool {
app.get_webview_window("pet")
.and_then(|pet| pet.is_visible().ok())
.unwrap_or(false)
}
/// Bring the main window back after it was hidden by closing it.
#[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(main) = app.get_webview_window("main") {
main.show().map_err(|e| e.to_string())?;
let _ = main.set_focus();
}
Ok(())
}
fn main() {
let desktop_backend = Arc::new(DesktopBackendState::default());
let launch_cwd = std::env::current_dir()
@@ -502,13 +549,63 @@ fn main() {
eprintln!("[desktop-backend] health check failed: {error}");
}
});
// Tray icon so the app can keep running after the main window is
// closed, with explicit Show / Quit actions.
if let Some(icon) = app.default_window_icon().cloned() {
let show_item =
MenuItem::with_id(app, "show_window", "Show Window", true, None::<&str>)?;
let quit_item =
MenuItem::with_id(app, "quit", "Quit Cline Code", true, None::<&str>)?;
let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?;
TrayIconBuilder::with_id("cline-tray")
.icon(icon)
.tooltip("Cline Code")
.menu(&tray_menu)
.show_menu_on_left_click(true)
.on_menu_event(|app, event| match event.id().as_ref() {
"show_window" => {
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
}
// Keep the pet floating above other apps and on every Space, so it
// stays visible even when the main window is minimized or hidden.
if let Some(pet) = app.get_webview_window("pet") {
let _ = pet.set_always_on_top(true);
let _ = pet.set_visible_on_all_workspaces(true);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_desktop_backend_endpoint,
pick_workspace_directory,
open_mcp_settings_file
open_mcp_settings_file,
start_pet_drag,
show_pet,
hide_pet,
is_pet_visible,
show_main_window
])
.on_window_event(|window, event| {
// Closing the main window hides it and keeps the app (and sidecar)
// running in the background; the tray or Dock reopens it, and
// Cmd+Q / the tray Quit item performs the real shutdown.
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
api.prevent_close();
let _ = window.hide();
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri app")
.run(|app_handle, event| match event {
@@ -518,6 +615,13 @@ fn main() {
.inner()
.stop();
}
// Clicking the Dock icon on macOS reopens the hidden main window.
RunEvent::Reopen { .. } => {
if let Some(main) = app_handle.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
}
_ => {}
});
}
@@ -10,6 +10,7 @@
"frontendDist": "../webview/out"
},
"app": {
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
@@ -17,6 +18,21 @@
"width": 1500,
"height": 980,
"resizable": true
},
{
"label": "pet",
"title": "Nyan Pet",
"width": 184,
"height": 120,
"resizable": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": false,
"focus": false,
"maximizable": false,
"minimizable": false
}
],
"security": {
@@ -19,6 +19,60 @@
}
}
/* Hero heading cycling verb (components/views/chat/welcome-chat.tsx) */
@keyframes hero-word-in {
0% {
opacity: 0;
transform: translateY(0.42em);
filter: blur(6px);
}
60% {
filter: blur(0);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.hero-word-char {
display: inline-block;
white-space: pre;
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
color: var(--brand-violet);
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
}
/*
* Gradient fill per character. The clip lives on each animated span (not a
* shared parent) because WebKit — used by the Tauri webview on macOS — drops
* the parent's background when a child paints on its own transform/filter
* layer, which would leave the animating letters blank. The -webkit- prefixes
* are required by WebKit; @supports keeps the solid fallback above otherwise.
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.hero-word-char {
background-image: linear-gradient(
135deg,
var(--brand-periwinkle),
var(--brand-violet) 55%,
var(--brand-magenta)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-word-char {
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
animation: none !important;
}
}
/* Aurora background (components/ui/aurora-bg.tsx) */
@keyframes aurora-drift {
0% {
+207 -120
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
import { NyanCat, PetWindowView } from "@/components/nyan-cat";
import {
AlertDialog,
AlertDialogAction,
@@ -29,13 +30,20 @@ import {
type SettingsSection,
SettingsView,
} from "@/components/views/settings/settings-view";
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import {
readChatBackground,
subscribeChatBackground,
} from "@/lib/chat-background";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
import { getCurrentWindowLabel, isTauri } from "@/lib/pet-window";
import {
getSessionMetadataTitle,
type SessionHistoryItem,
@@ -43,6 +51,7 @@ import {
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
filterWorkspacePaths,
mergeWorkspacePaths,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
@@ -71,6 +80,35 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
// Both the main and the floating-pet windows load this same page; branch on
// the Tauri window label so the pet window renders only the pet. Render
// nothing until resolved so the client-only branches never hydrate wrong.
const [windowKind, setWindowKind] = useState<"pending" | "main" | "pet">(
"pending",
);
useEffect(() => {
let active = true;
void getCurrentWindowLabel().then((label) => {
if (active) {
setWindowKind(label === "pet" ? "pet" : "main");
}
});
return () => {
active = false;
};
}, []);
if (windowKind === "pending") {
return null;
}
if (windowKind === "pet") {
return <PetWindowView />;
}
return <MainApp />;
}
function MainApp() {
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("General");
@@ -86,6 +124,10 @@ export default function Home() {
return watchSystemHubTheme();
}, []);
useEffect(() => {
void syncDesktopWindowTitle();
}, []);
const handleNewThread = useCallback(() => {
const id = makeThreadId();
setThreads((prev) => [...prev, { id }]);
@@ -219,63 +261,69 @@ export default function Home() {
);
return (
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
<AgentSidebar
activeSessionId={activeHistorySessionId}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
<AccountProvider>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
{!isTauri() ? <NyanCat /> : null}
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
>
<AgentSidebar
activeSessionId={activeHistorySessionId}
history={sessionHistory}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
</AccountProvider>
);
}
@@ -341,14 +389,26 @@ function ChatThreadPane({
string | null
>(null);
const [gitBranch, setGitBranch] = useState("no-git");
const [chatBackground, setChatBackground] = useState<string | null>(null);
useEffect(() => {
setChatBackground(readChatBackground());
return subscribeChatBackground(() =>
setChatBackground(readChatBackground()),
);
}, []);
const [providerCredentials, setProviderCredentials] = useState<
Record<string, { apiKey: string }>
>({});
const [providersLoaded, setProvidersLoaded] = useState(false);
// History paths lead each merge: they are ordered by session recency, so
// stored or stale entries only append after them.
const [workspaces, setWorkspaces] = useState<string[]>(() =>
mergeWorkspacePaths(
readWorkspaceSelectionFromWindow().workspaces,
knownWorkspacePaths,
filterWorkspacePaths(
mergeWorkspacePaths(
knownWorkspacePaths,
readWorkspaceSelectionFromWindow().workspaces,
),
),
);
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
@@ -366,7 +426,9 @@ function ChatThreadPane({
useEffect(() => {
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
const merged = filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, current),
);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -508,7 +570,12 @@ function ChatThreadPane({
workspaceRef.current.cwd ||
""
).trim();
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
// The active workspace can be an excluded path (restored session,
// process cwd fallback); it renders via its own registration in the
// selector and welcome screen instead of joining the catalog.
return filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]),
);
},
[knownWorkspacePaths],
);
@@ -518,7 +585,7 @@ function ChatThreadPane({
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, results);
const merged = mergeWorkspacePaths(results, current);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -562,7 +629,9 @@ function ChatThreadPane({
workspaceRoot: nextWorkspace,
cwd: nextWorkspace,
}));
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
setWorkspaces((prev) =>
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
);
// Fire git branch + workspace list refresh in the background
desktopClient
@@ -1045,68 +1114,86 @@ function ChatThreadPane({
return (
<WorkspaceProvider value={workspaceContextValue}>
<div
className={
isWelcomeState
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
}
>
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) setShowDiffView(true);
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{chatBackground ? (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url("${chatBackground}")` }}
/>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-background/80"
/>
</>
) : null}
<WelcomeScreen
active={isWelcomeState}
body={
showDiffView ? (
<DiffView
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
) : (
<ChatMessages
onAnswerAskQuestion={handleAnswerAskQuestion}
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
onRestoreCheckpoint={(runCount) =>
void restoreCheckpoint(runCount)
}
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
pendingAskQuestions={pendingAskQuestions}
sessionId={displayedSessionId}
streamingMessageId={activeAssistantMessageId}
isSessionSwitching={displayedIsSwitching}
status={displayedStatus}
/>
)
<div
className={
isWelcomeState
? "relative z-10 grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "relative z-10 grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
}
composer={composer}
onStartChat={setPromptInput}
quickActions={[]}
/>
>
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) setShowDiffView(true);
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
/>
</div>
) : null}
<WelcomeScreen
active={isWelcomeState}
body={
showDiffView ? (
<DiffView
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
) : (
<ChatMessages
onAnswerAskQuestion={handleAnswerAskQuestion}
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
onRestoreCheckpoint={(runCount) =>
void restoreCheckpoint(runCount)
}
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
pendingAskQuestions={pendingAskQuestions}
sessionId={displayedSessionId}
streamingMessageId={activeAssistantMessageId}
isSessionSwitching={displayedIsSwitching}
status={displayedStatus}
/>
)
}
composer={composer}
gitBranch={gitBranch}
onListGitBranches={listGitBranches}
onStartChat={setPromptInput}
onSwitchGitBranch={switchGitBranch}
quickActions={[]}
/>
</div>
</div>
<AlertDialog
open={deleteConfirmOpen}
@@ -5,11 +5,15 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSidebar } from "@/components/agent-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AccountProvider } from "@/contexts/account-context";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
let container: HTMLDivElement;
let root: Root;
@@ -78,6 +82,9 @@ function sessionIsVisible(title: string): boolean {
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({
@@ -173,4 +180,154 @@ describe("AgentSidebar session organization", () => {
await click(buttonWithText("Load older projects"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
});
it("shows the signed-in account and active organization in the footer", async () => {
invoke.mockResolvedValue({
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
photoUrl: "",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [
{
active: true,
memberId: "member-1",
name: "Cline Bot Inc",
organizationId: "org-1",
roles: ["admin"],
},
],
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Beatrix");
expect(container.textContent).toContain("Cline Bot Inc");
});
expect(container.textContent).not.toContain("Cline Desktop");
expect(container.textContent).not.toContain("Local");
});
it("opens the Account settings section when the footer account row is clicked", async () => {
const setView = vi.fn();
const onSettingsSectionChange = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={onSettingsSectionChange}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={setView}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const accountButton = container.querySelector(
'[aria-label="Account settings"]',
);
expect(accountButton).not.toBeNull();
await click(accountButton as Element);
expect(onSettingsSectionChange).toHaveBeenCalledWith("Account");
expect(setView).toHaveBeenCalledWith("settings");
});
it("shows the desktop app version in a popover when the Cline logo is clicked", async () => {
const onHome = vi.fn();
invoke.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { appVersion: "1.2.3" };
}
throw new Error("No Cline account auth token found");
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={onHome}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const logoButton = container.querySelector('[aria-label="Cline home"]');
expect(logoButton).not.toBeNull();
expect(document.body.textContent).not.toContain("Version 1.2.3");
await click(logoButton as Element);
await vi.waitFor(() => {
expect(document.body.textContent).toContain("Version 1.2.3");
});
expect(onHome).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith("get_process_context");
});
it("falls back to a signed-out footer without account data", async () => {
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Cline Desktop");
});
expect(container.textContent).not.toContain("Local");
});
});
@@ -10,7 +10,6 @@ import {
Filter,
FolderTree,
GitFork,
Home,
Loader2,
MessageSquare,
PanelLeftOpen,
@@ -64,6 +63,11 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useSidebar } from "@/components/ui/sidebar";
import { normalizeTitle } from "@/components/utils";
@@ -71,11 +75,13 @@ import {
SETTINGS_SECTIONS,
type SettingsSection,
} from "@/components/views/settings/settings-view";
import { useAccount } from "@/contexts/account-context";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
import { desktopClient } from "@/lib/desktop-client";
import {
groupThreadsByProject,
INITIAL_VISIBLE_THREAD_COUNT,
@@ -172,6 +178,14 @@ export function AgentSidebar({
}) {
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
const isCollapsed = !isMobile && state === "collapsed";
const { user, activeOrganization } = useAccount();
const { displayName, email } = user || {};
const username = displayName?.split(" ")?.[0] || email?.split("@")?.[0];
const accountName = username?.trim() || "Cline Desktop";
const accountScope = user
? (activeOrganization?.name ?? "Personal")
: undefined;
const accountInitial = accountName.charAt(0).toUpperCase();
const {
deleteThread: deleteHistoryThread,
forkThread: forkHistoryThread,
@@ -205,6 +219,26 @@ export function AgentSidebar({
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
Record<string, number>
>({});
const [appVersion, setAppVersion] = useState<string | null>(null);
const loadAppVersion = useCallback(async () => {
try {
const context = await desktopClient.invoke<{ appVersion?: unknown }>(
"get_process_context",
);
const version =
typeof context?.appVersion === "string"
? context.appVersion.trim()
: "";
setAppVersion(version || null);
} catch {
// Leave the version hidden; an older sidecar build has no appVersion.
}
}, []);
useEffect(() => {
void loadAppVersion();
}, [loadAppVersion]);
useEffect(() => {
if (isCollapsed && searchOpen) {
@@ -446,14 +480,31 @@ export function AgentSidebar({
isCollapsed && "justify-center px-0",
)}
>
<button
aria-label="Cline home"
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={openHome}
type="button"
<Popover
onOpenChange={(open) => {
if (open && !appVersion) {
void loadAppVersion();
}
}}
>
<ClineLogo className="h-6 w-6" />
</button>
<PopoverTrigger asChild>
<button
aria-label="Cline home"
className="flex items-center gap-2 rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
type="button"
onClick={openHome}
title="Home"
>
<ClineLogo className="h-6 w-6" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-3" side="bottom">
<p className="text-sm font-medium">Cline Code</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{appVersion ? `Version ${appVersion}` : "Version unavailable"}
</p>
</PopoverContent>
</Popover>
</div>
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
@@ -465,13 +516,13 @@ export function AgentSidebar({
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
aria-label="Home"
aria-label="New Session"
onClick={openHome}
title="Home"
title="New Session"
variant="sidebarItem"
>
<Home className="size-4" />
{!isCollapsed ? "Home" : null}
<Plus className="size-4" />
{!isCollapsed ? "New Session" : null}
</Button>
</div>
@@ -542,17 +593,6 @@ export function AgentSidebar({
</Button>
{sortMenu}
{filterMenu}
<Button
aria-label="New session"
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
onClick={openNewThread}
size="icon"
title="New session"
type="button"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</div>
</div>
{searchOpen ? (
@@ -679,36 +719,47 @@ export function AgentSidebar({
)}
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"min-w-0 justify-start",
view === "settings" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
{view !== "settings" && (
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"min-w-0 justify-start",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
)}
{!isCollapsed ? (
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
C
</span>
<span className="min-w-0">
<button
aria-label="Account settings"
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
view === "settings" &&
settingsSection === "Account" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
)}
onClick={() => openSettingsSection("Account")}
title={user?.email || undefined}
type="button"
>
<span className="min-w-0 flex gap-2 items-center">
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
{accountInitial}
</span>
<span className="block truncate text-sm font-medium">
Cline Desktop
</span>
<span className="block text-[11px] text-muted-foreground">
Local
{accountName}
<span className="pl-1 truncate text-[11px] text-muted-foreground">
{accountScope}
</span>
</span>
</span>
</div>
</button>
) : null}
</div>
</div>
@@ -881,7 +932,7 @@ function ThreadItem({
<HoverCardTrigger asChild>
<button
className={cn(
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
@@ -0,0 +1,238 @@
"use client";
import { AppWindow, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_NYAN_PET_SRC,
getNyanPetSrc,
subscribeNyanPet,
} from "@/lib/nyan-pet";
import { hidePet, showMainWindow, startPetDrag } from "@/lib/pet-window";
const NYAN_WIDTH = 160;
const NYAN_HEIGHT = 96;
const MARGIN = 32;
/**
* Shared pet media: the current gif source (kept in sync with Settings) plus an
* audio element that plays only while the pet is hovered or being dragged.
*/
function useNyanPetMedia() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [petSrc, setPetSrc] = useState(DEFAULT_NYAN_PET_SRC);
const [hovering, setHovering] = useState(false);
const [dragging, setDragging] = useState(false);
useEffect(() => {
setPetSrc(getNyanPetSrc());
return subscribeNyanPet(() => setPetSrc(getNyanPetSrc()));
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (hovering || dragging) {
void audio.play().catch(() => {});
} else {
audio.pause();
audio.currentTime = 0;
}
}, [hovering, dragging]);
return { audioRef, petSrc, hovering, setHovering, dragging, setDragging };
}
/**
* In-page pet used in plain web/dev mode (no Tauri). Free-floating and draggable
* within the window; its theme song plays while hovered or dragged. In the
* desktop app the pet lives in its own always-on-top window (see PetWindowView).
*/
export function NyanCat() {
const [visible, setVisible] = useState(true);
const [position, setPosition] = useState({ x: MARGIN, y: MARGIN });
const { audioRef, petSrc, dragging, setDragging, setHovering } =
useNyanPetMedia();
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
setPosition({
x: Math.max(MARGIN, window.innerWidth - NYAN_WIDTH - MARGIN),
y: Math.max(MARGIN, window.innerHeight - NYAN_HEIGHT - MARGIN),
});
}, []);
const clamp = useCallback((x: number, y: number) => {
const maxX = Math.max(0, window.innerWidth - NYAN_WIDTH);
const maxY = Math.max(0, window.innerHeight - NYAN_HEIGHT);
return {
x: Math.min(Math.max(0, x), maxX),
y: Math.min(Math.max(0, y), maxY),
};
}, []);
useEffect(() => {
if (!dragging) {
return;
}
const handleMove = (event: PointerEvent) => {
const offset = dragOffsetRef.current;
if (!offset) {
return;
}
setPosition(clamp(event.clientX - offset.x, event.clientY - offset.y));
};
const stop = () => {
dragOffsetRef.current = null;
setDragging(false);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [dragging, clamp, setDragging]);
useEffect(() => {
const handleResize = () =>
setPosition((current) => clamp(current.x, current.y));
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [clamp]);
if (!visible) {
return null;
}
return (
<div
className="group fixed z-50 cursor-grab select-none active:cursor-grabbing"
onPointerDown={(event) => {
if ((event.target as HTMLElement).closest("[data-nyan-control]")) {
return;
}
event.preventDefault();
dragOffsetRef.current = {
x: event.clientX - position.x,
y: event.clientY - position.y,
};
setDragging(true);
}}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
style={{ left: position.x, top: position.y, width: NYAN_WIDTH }}
>
<button
aria-label="Hide Nyan Cat"
className="absolute -right-2 -top-2 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
data-nyan-control=""
onClick={() => setVisible(false)}
type="button"
>
<X className="size-3" />
</button>
{/* biome-ignore lint/performance/noImgElement: static public asset, not statically optimizable */}
<img
alt="Desktop pet"
className="pointer-events-none w-full drop-shadow-lg"
draggable={false}
height={NYAN_HEIGHT}
src={petSrc}
width={NYAN_WIDTH}
/>
{/* biome-ignore lint/a11y/useMediaCaption: decorative background music */}
<audio loop preload="auto" ref={audioRef} src="/nyantune.mp3" />
</div>
);
}
/**
* The pet as rendered inside its own transparent, always-on-top Tauri window.
* Dragging moves the OS window (so it can go anywhere on screen, even when the
* main window is minimized), and the dismiss button hides the window.
*/
export function PetWindowView() {
const { audioRef, petSrc, dragging, setDragging, setHovering } =
useNyanPetMedia();
// Make the window chrome see-through so only the pet shows.
useEffect(() => {
const root = document.documentElement;
const body = document.body;
const prevRoot = root.style.background;
const prevBody = body.style.background;
root.style.background = "transparent";
body.style.background = "transparent";
return () => {
root.style.background = prevRoot;
body.style.background = prevBody;
};
}, []);
// The OS drag can swallow the pointerup; reset on any pointer release.
useEffect(() => {
if (!dragging) {
return;
}
const stop = () => setDragging(false);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [dragging, setDragging]);
return (
<div className="group fixed inset-0 flex select-none items-center justify-center">
<div
className="relative cursor-grab active:cursor-grabbing"
onPointerDown={(event) => {
if ((event.target as HTMLElement).closest("[data-nyan-control]")) {
return;
}
setDragging(true);
void startPetDrag();
}}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
>
<button
aria-label="Open Cline window"
className="absolute -left-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
data-nyan-control=""
onClick={() => void showMainWindow()}
title="Open Cline"
type="button"
>
<AppWindow className="size-3" />
</button>
<button
aria-label="Hide desktop pet"
className="absolute -right-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
data-nyan-control=""
onClick={() => void hidePet()}
title="Hide pet"
type="button"
>
<X className="size-3" />
</button>
{/* biome-ignore lint/performance/noImgElement: static public asset, not statically optimizable */}
<img
alt="Desktop pet"
className="pointer-events-none drop-shadow-lg"
draggable={false}
height={NYAN_HEIGHT}
src={petSrc}
width={NYAN_WIDTH}
/>
</div>
{/* biome-ignore lint/a11y/useMediaCaption: decorative background music */}
<audio loop preload="auto" ref={audioRef} src="/nyantune.mp3" />
</div>
);
}
@@ -313,8 +313,8 @@ function Sidebar({
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@@ -13,14 +13,6 @@ import {
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
@@ -42,6 +34,7 @@ import {
loadProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { SearchableSelect } from "./searchable-select";
import { WorkspaceSelector } from "./workspace-selector";
type ActiveMention = {
@@ -1045,7 +1038,7 @@ export function ChatInputBar({
ref={fileInputRef}
type="file"
/>
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
<div className="hidden flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
<button
aria-pressed={mode === "plan"}
className={cn(
@@ -1087,7 +1080,6 @@ export function ChatInputBar({
}
onProviderChange={onProviderChange}
provider={provider}
variant={variant}
/>
</div>
<Select
@@ -1097,7 +1089,7 @@ export function ChatInputBar({
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
size="sm"
title={
modelSupportsReasoning === false
@@ -1128,7 +1120,7 @@ export function ChatInputBar({
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
<div className="hidden max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
<WorkspaceSelector
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
@@ -1181,7 +1173,6 @@ function ModelSelector({
provider,
model,
isBusy,
variant,
onProviderChange,
onModelChange,
onModelSupportsReasoningChange,
@@ -1189,7 +1180,6 @@ function ModelSelector({
provider: string;
model: string;
isBusy: boolean;
variant: "conversation" | "welcome";
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
@@ -1416,13 +1406,13 @@ function ModelSelector({
]);
return (
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
<Combobox
<div className="flex min-w-0 shrink-0 items-center gap-0.5 text-[11px]">
<SearchableSelect
ariaLabel="Provider"
disabled={isBusy || providers.length === 0}
emptyLabel="No providers found."
items={providers}
onValueChange={(value) => {
if (!value) {
return;
}
onSelect={(value) => {
onProviderChange(value);
const rememberedModel = lastSelection.lastModelByProvider[value];
const providerModelIds = visibleProviderModels[value] ?? [];
@@ -1439,63 +1429,23 @@ function ModelSelector({
onModelChange(firstModel);
}
}}
placeholder="Provider"
searchPlaceholder="Search providers"
triggerClassName="max-w-28 text-[11px]"
value={resolvedProvider}
>
<ComboboxInput
aria-label="Provider"
className={cn(
"h-7 text-[11px] max-[560px]:w-20",
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || providers.length === 0}
readOnly
showClear={false}
showTrigger
/>
<ComboboxContent>
<ComboboxEmpty>No providers found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox
/>
<span className="text-muted-foreground/50">/</span>
<SearchableSelect
ariaLabel="Model"
disabled={isBusy || modelsForProvider.length === 0}
emptyLabel="No models found."
items={modelsForProvider}
onValueChange={(value) => {
if (!value) {
return;
}
onModelChange(value);
}}
onSelect={(value) => onModelChange(value)}
placeholder="Model"
searchPlaceholder="Search models"
triggerClassName="max-w-52 text-[11px]"
value={resolvedModel}
>
<ComboboxInput
aria-label="Model"
className={cn(
"h-7 text-[11px] max-[560px]:w-32",
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || modelsForProvider.length === 0}
readOnly
showClear={false}
showTrigger
/>
<ComboboxContent>
<ComboboxEmpty>No models found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { Check, Search } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
/**
* A button-styled select whose menu is a searchable, filterable list — the same
* interaction the workspace and branch pickers use. The trigger shows the
* current value with no chevron; clicking it opens the popover.
*/
export function SearchableSelect({
value,
items,
onSelect,
disabled = false,
ariaLabel,
searchPlaceholder = "Search...",
emptyLabel = "No results",
placeholder = "Select",
icon,
triggerClassName,
align = "start",
placement = "top",
}: {
value: string;
items: string[];
onSelect: (value: string) => void;
disabled?: boolean;
ariaLabel: string;
searchPlaceholder?: string;
emptyLabel?: string;
placeholder?: string;
icon?: ReactNode;
triggerClassName?: string;
align?: "start" | "end";
placement?: "top" | "bottom";
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click; reset the filter each time the menu opens.
useEffect(() => {
if (!open) {
setSearch("");
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
// pointerdown in the capture phase so we still fire before a portaled menu
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
// calls preventDefault, which would otherwise suppress a mousedown listener.
document.addEventListener("pointerdown", handlePointerDown, true);
return () =>
document.removeEventListener("pointerdown", handlePointerDown, true);
}, [open]);
const filtered = useMemo(
() =>
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
[items, search],
);
const handleSelect = (item: string) => {
if (item !== value) onSelect(item);
setOpen(false);
};
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
triggerClassName,
)}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
title={value}
type="button"
>
{icon}
<span className="truncate">{value || placeholder}</span>
</button>
{open && (
<div
className={cn(
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
align === "end" ? "right-0" : "left-0",
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder}
value={search}
/>
</div>
</div>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{emptyLabel}
</div>
) : (
filtered.map((item) => (
<button
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
item === value ? "bg-accent" : "hover:bg-accent/50",
)}
key={item}
onClick={() => handleSelect(item)}
type="button"
>
<span className="truncate text-xs text-foreground">
{item}
</span>
{item === value && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -1,12 +1,12 @@
"use client";
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
import { ArrowRight } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
interface QuickAction {
id: string;
@@ -15,6 +15,9 @@ interface QuickAction {
prompt: string;
}
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
const HERO_CYCLE_MS = 2600;
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
{
id: "review-changes",
@@ -30,33 +33,44 @@ const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
},
];
function toWorkspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "Workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "Workspace";
}
function HeroHeading() {
const [verbIndex, setVerbIndex] = useState(0);
function workspaceLabels(paths: string[]): Map<string, string> {
const segments = paths.map((path) =>
path
.trim()
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.filter(Boolean),
);
return new Map(
paths.map((path, index) => {
const parts = segments[index] ?? [];
for (let depth = 1; depth <= parts.length; depth += 1) {
const candidate = parts.slice(-depth).join("/");
const matches = segments.filter(
(other) => other.slice(-depth).join("/") === candidate,
).length;
if (matches === 1) return [path, candidate];
}
return [path, toWorkspaceName(path)];
}),
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
if (media.matches) return;
const interval = setInterval(() => {
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
}, HERO_CYCLE_MS);
return () => clearInterval(interval);
}, []);
const verb = HERO_VERBS[verbIndex];
return (
<h1
id="hero-header"
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
>
<span className="sr-only">What would you like to build?</span>
<span aria-hidden="true">
What would you like to{" "}
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
<span key={verb}>
{verb.split("").map((char, index) => (
<span
className="hero-word-char"
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
key={`${verb}-${index}`}
style={{ animationDelay: `${index * 45}ms` }}
>
{char}
</span>
))}
</span>
?
</span>
</h1>
);
}
@@ -66,12 +80,18 @@ export function WelcomeScreen({
composer,
onStartChat,
quickActions,
gitBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
active: boolean;
body: ReactNode;
composer: ReactNode;
onStartChat: (prompt: string) => void;
quickActions: QuickAction[];
gitBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const {
workspaceRoot,
@@ -80,61 +100,13 @@ export function WelcomeScreen({
switchWorkspace,
pickWorkspaceDirectory,
} = useWorkspace();
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
null,
);
const [addingWorkspace, setAddingWorkspace] = useState(false);
const availableWorkspaces = useMemo(() => {
const next = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const workspacePath of workspaces) register(workspacePath);
return [...next.values()];
}, [workspaceRoot, workspaces]);
const actions =
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
const labelsByWorkspace = useMemo(
() => workspaceLabels(availableWorkspaces),
[availableWorkspaces],
);
useEffect(() => {
if (active) void refreshWorkspaces();
}, [active, refreshWorkspaces]);
const handleSelectWorkspace = useCallback(
async (path: string) => {
if (
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot) ||
switchingWorkspace
) {
return;
}
setSwitchingWorkspace(path);
try {
await switchWorkspace(path);
} finally {
setSwitchingWorkspace(null);
}
},
[switchWorkspace, switchingWorkspace, workspaceRoot],
);
const handleAddWorkspace = useCallback(async () => {
if (addingWorkspace) return;
setAddingWorkspace(true);
try {
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
if (selected) await switchWorkspace(selected);
} finally {
setAddingWorkspace(false);
}
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
return (
<div
className={cn(
@@ -154,60 +126,25 @@ export function WelcomeScreen({
<div
className={cn(
active
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
: "contents",
)}
>
{active ? (
<>
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
What would you like to build?
</h1>
<HeroHeading />
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
<legend className="sr-only">Workspaces</legend>
{availableWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot);
const isSwitching = switchingWorkspace === path;
return (
<button
aria-pressed={isActive}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
disabled={Boolean(switchingWorkspace)}
key={path}
onClick={() => void handleSelectWorkspace(path)}
title={path}
type="button"
>
{isSwitching
? "Switching..."
: (labelsByWorkspace.get(path) ??
toWorkspaceName(path))}
</button>
);
})}
</fieldset>
<button
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
disabled={addingWorkspace}
onClick={() => void handleAddWorkspace()}
type="button"
>
{addingWorkspace ? (
<FolderPlus className="size-4 animate-pulse" />
) : (
<Plus className="size-4" />
)}
New project
</button>
<div className="mt-11 flex min-w-0 items-center">
<WelcomeWorkspaceControls
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
onPickWorkspaceDirectory={pickWorkspaceDirectory}
onRefreshWorkspaces={refreshWorkspaces}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={switchWorkspace}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
</div>
</>
) : null}
@@ -0,0 +1,400 @@
"use client";
import { Check, Folder, GitBranch, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
const linuxHome = path.match(/^\/home\/[^/]+\/(.*)$/);
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
const windowsHome = path.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
if (windowsHome) {
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
return tail ? `~/${tail}` : "~";
}
return path;
}
function workspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
}
const TRIGGER_CLASS =
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const PANEL_CLASS =
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
function SearchInput({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value}
/>
</div>
</div>
);
}
function WorkspacePicker({
open,
onToggle,
onClose,
workspaceRoot,
workspaces,
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
}) {
const [search, setSearch] = useState("");
const [switching, setSwitching] = useState(false);
const [picking, setPicking] = useState(false);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspacePath(workspaceRoot),
[workspaceRoot],
);
// Refresh the catalog and clear the filter each time the menu opens.
useEffect(() => {
if (!open) return;
setSearch("");
void onRefreshWorkspaces();
}, [open, onRefreshWorkspaces]);
// The active workspace can be an excluded path (restored session, process
// cwd fallback); register it explicitly so it stays visible while active.
const availableWorkspaces = useMemo(() => {
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed)
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((path) =>
path.toLowerCase().includes(search.toLowerCase()),
);
const handleSelect = async (path: string) => {
const next = path.trim();
if (!next || normalizeWorkspacePath(next) === normalizedWorkspaceRoot) {
onClose();
return;
}
if (switching) return;
setSwitching(true);
const switched = await onSwitchWorkspace(next);
setSwitching(false);
if (switched) onClose();
};
const handleAddWorkspace = async () => {
if (picking || switching) return;
setPicking(true);
try {
const picked = await onPickWorkspaceDirectory(workspaceRoot || undefined);
if (picked?.trim()) await handleSelect(picked.trim());
} finally {
setPicking(false);
}
};
return (
<div className="relative shrink-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={TRIGGER_CLASS}
onClick={onToggle}
title={workspaceRoot}
type="button"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">
{workspaceName(workspaceRoot)}
</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search workspaces"
value={search}
/>
<div className="p-1.5">
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
{filteredWorkspaces.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No workspaces found
</div>
) : (
filteredWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) === normalizedWorkspaceRoot;
return (
<Button
className={cn(
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
isActive ? "bg-accent" : "hover:bg-accent/50",
)}
disabled={switching}
key={path}
onClick={() => void handleSelect(path)}
variant="ghost"
>
<span className="flex min-w-0 items-center gap-2">
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-foreground">
{formatWorkspacePath(path)}
</span>
</span>
{isActive && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</Button>
);
})
)}
</div>
<Button
className="mt-0.5 w-full justify-start text-xs text-muted-foreground"
disabled={switching || picking}
onClick={() => void handleAddWorkspace()}
size="sm"
variant="ghost"
>
<Plus className="size-3" />
{picking ? "Opening folder picker..." : "Add project..."}
</Button>
</div>
</div>
)}
</div>
);
}
function BranchPicker({
open,
onToggle,
onClose,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [search, setSearch] = useState("");
const [branches, setBranches] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [switching, setSwitching] = useState(false);
// Load branches fresh each time the menu opens.
useEffect(() => {
if (!open) return;
let cancelled = false;
setSearch("");
setLoading(true);
onListGitBranches()
.then((payload) => {
if (!cancelled) setBranches(payload.branches);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, onListGitBranches]);
const hasGit = currentBranch !== "no-git";
const branchLabel = hasGit ? currentBranch : "No branch";
const filteredBranches = branches.filter((branch) =>
branch.toLowerCase().includes(search.toLowerCase()),
);
const handleSelect = async (branch: string) => {
if (branch === currentBranch) {
onClose();
return;
}
if (switching) return;
setSwitching(true);
const switched = await onSwitchGitBranch(branch);
setSwitching(false);
if (switched) onClose();
};
return (
<div className="relative min-w-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
onClick={onToggle}
title={branchLabel}
type="button"
>
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{branchLabel}</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search branches"
value={search}
/>
<div className="p-1.5">
{loading ? (
<div className="px-2 py-4 text-xs text-muted-foreground">
Loading...
</div>
) : (
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
</div>
) : (
filteredBranches.map((branch) => (
<Button
className={cn(
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
currentBranch === branch
? "bg-accent"
: "hover:bg-accent/50",
)}
disabled={switching}
key={branch}
onClick={() => void handleSelect(branch)}
variant="ghost"
>
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{branch}
</span>
{currentBranch === branch && (
<Check className="ml-auto size-3 shrink-0 text-foreground" />
)}
</Button>
))
)}
</div>
)}
</div>
</div>
)}
</div>
);
}
export function WelcomeWorkspaceControls({
workspaceRoot,
workspaces,
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Close whichever menu is open when clicking outside the control row.
useEffect(() => {
if (!openMenu) return;
const handlePointerDown = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpenMenu(null);
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [openMenu]);
return (
<div className="flex min-w-0 items-center gap-2" ref={containerRef}>
<WorkspacePicker
onClose={() => setOpenMenu(null)}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={onRefreshWorkspaces}
onSwitchWorkspace={onSwitchWorkspace}
onToggle={() =>
setOpenMenu((current) =>
current === "workspace" ? null : "workspace",
)
}
open={openMenu === "workspace"}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
/>
</div>
);
}
@@ -79,4 +79,30 @@ describe("WorkspaceSelector", () => {
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
});
});
it("lists the active workspace even when the catalog excludes it", async () => {
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/Users/beatrix/Desktop"
workspaces={["/workspace/one"]}
/>,
);
});
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("~/Desktop");
expect(container.textContent).toContain("/workspace/one");
});
});
});
@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
@@ -19,17 +20,6 @@ function formatWorkspacePath(path: string): string {
return path;
}
function normalizeWorkspacePath(path: string): string {
const normalized = path.trim().replace(/[\\/]+$/, "");
if (!normalized) {
return "";
}
if (/^[A-Za-z]:/.test(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
export function WorkspaceSelector({
currentBranch,
workspaceRoot,
@@ -181,7 +171,20 @@ export function WorkspaceSelector({
b.toLowerCase().includes(search.toLowerCase()),
);
const filteredWorkspaces = workspaces.filter((w) =>
// The catalog excludes non-project paths (home, Desktop, ~/.cline), but an
// explicitly opened workspace must stay visible while it is active.
const availableWorkspaces = useMemo(() => {
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((w) =>
w.toLowerCase().includes(search.toLowerCase()),
);
@@ -1,6 +1,19 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
MAX_CHAT_BACKGROUND_BYTES,
readChatBackground,
setChatBackground,
} from "@/lib/chat-background";
import { desktopClient } from "@/lib/desktop-client";
import {
DEFAULT_NYAN_PET_SRC,
MAX_NYAN_PET_BYTES,
readStoredPetGif,
setStoredPetGif,
} from "@/lib/nyan-pet";
import { hidePet, isPetVisible, isTauri, showPet } from "@/lib/pet-window";
import type {
Provider,
ProviderCatalogResponse,
@@ -581,7 +594,261 @@ function GeneralSettingsContent() {
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
/>
</div>
<NyanPetSetting />
<PetWindowToggle />
<ChatBackgroundSetting />
</section>
</PageFrame>
);
}
function ChatBackgroundSetting() {
const inputRef = useRef<HTMLInputElement | null>(null);
const [background, setBackground] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setBackground(readChatBackground());
}, []);
const handleFile = useCallback((file: File | undefined) => {
if (!file) {
return;
}
setError(null);
if (!file.type.startsWith("image/")) {
setError("Please choose an image file.");
return;
}
if (file.size > MAX_CHAT_BACKGROUND_BYTES) {
setError("That image is too large. Please pick one under 3 MB.");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : null;
if (!dataUrl) {
setError("Could not read that image.");
return;
}
try {
setChatBackground(dataUrl);
setBackground(dataUrl);
} catch {
setError("Could not save that image — it may be too large to store.");
}
};
reader.onerror = () => setError("Could not read that image.");
reader.readAsDataURL(file);
}, []);
const handleReset = useCallback(() => {
setChatBackground(null);
setBackground(null);
setError(null);
if (inputRef.current) {
inputRef.current.value = "";
}
}, []);
return (
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Chat background
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Upload an image to show behind your chat conversation.
</p>
{error ? (
<p className="mt-2 text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 max-[720px]:justify-end">
<div className="flex h-14 w-24 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{background ? (
// biome-ignore lint/performance/noImgElement: user-provided data URL, not statically optimizable
<img
alt="MCP background preview"
className="h-full w-full object-cover"
src={background}
/>
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</div>
<input
accept="image/gif,image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
handleFile(event.target.files?.[0]);
event.target.value = "";
}}
ref={inputRef}
type="file"
/>
<Button
onClick={() => inputRef.current?.click()}
type="button"
variant="outline"
>
Upload image
</Button>
{background ? (
<Button onClick={handleReset} type="button" variant="ghost">
Reset
</Button>
) : null}
</div>
</div>
);
}
function PetWindowToggle() {
const [inDesktopApp, setInDesktopApp] = useState(false);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!isTauri()) {
return;
}
setInDesktopApp(true);
void isPetVisible().then(setVisible);
}, []);
const toggle = useCallback(async (next: boolean) => {
setVisible(next);
if (next) {
await showPet();
} else {
await hidePet();
}
}, []);
// Only meaningful in the desktop app, where the pet is its own OS window.
if (!inDesktopApp) {
return null;
}
return (
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Show floating pet
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Float the pet on top of your screen so it stays visible even when this
window is minimized or closed.
</p>
</div>
<Switch
aria-label="Show floating pet"
checked={visible}
onCheckedChange={(checked) => void toggle(checked)}
/>
</div>
);
}
function NyanPetSetting() {
const inputRef = useRef<HTMLInputElement | null>(null);
const [petGif, setPetGif] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setPetGif(readStoredPetGif());
}, []);
const handleFile = useCallback((file: File | undefined) => {
if (!file) {
return;
}
setError(null);
if (!file.type.startsWith("image/")) {
setError("Please choose an image file — an animated GIF works best.");
return;
}
if (file.size > MAX_NYAN_PET_BYTES) {
setError("That image is too large. Please pick one under 3 MB.");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : null;
if (!dataUrl) {
setError("Could not read that image.");
return;
}
try {
setStoredPetGif(dataUrl);
setPetGif(dataUrl);
} catch {
setError("Could not save that image — it may be too large to store.");
}
};
reader.onerror = () => setError("Could not read that image.");
reader.readAsDataURL(file);
}, []);
const handleReset = useCallback(() => {
setStoredPetGif(null);
setPetGif(null);
setError(null);
if (inputRef.current) {
inputRef.current.value = "";
}
}, []);
const previewSrc = petGif ?? DEFAULT_NYAN_PET_SRC;
return (
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">Desktop pet</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Replace Nyan Cat with your own GIF. It floats on top of the app, and
its tune plays while you hover or drag it.
</p>
{error ? (
<p className="mt-2 text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 max-[720px]:justify-end">
<div className="flex h-14 w-20 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{/* biome-ignore lint/performance/noImgElement: user-provided data URL, not statically optimizable */}
<img
alt="Desktop pet preview"
className="max-h-full max-w-full object-contain"
src={previewSrc}
/>
</div>
<input
accept="image/gif,image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
handleFile(event.target.files?.[0]);
event.target.value = "";
}}
ref={inputRef}
type="file"
/>
<Button
onClick={() => inputRef.current?.click()}
type="button"
variant="outline"
>
Upload GIF
</Button>
{petGif ? (
<Button onClick={handleReset} type="button" variant="ghost">
Reset
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import type { ClineAccountUser } from "@cline/core";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ACCOUNT_IDENTITY_STORAGE_KEY,
AccountProvider,
isSignedOutAccountError,
parseCachedAccountUser,
useAccount,
} from "./account-context";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
function makeUser(overrides: Partial<ClineAccountUser> = {}): ClineAccountUser {
return {
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
photoUrl: "",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [],
...overrides,
};
}
function Probe() {
const { user, activeOrganization } = useAccount();
return (
<div>
<span data-testid="account-name">{user?.displayName ?? "none"}</span>
<span data-testid="account-org">
{activeOrganization?.name ?? "none"}
</span>
</div>
);
}
let container: HTMLDivElement;
let root: Root;
function probeText(testId: string): string | null | undefined {
return container.querySelector(`[data-testid="${testId}"]`)?.textContent;
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("account context", () => {
it("parses only cached payloads that look like an account user", () => {
expect(parseCachedAccountUser(null)).toBeNull();
expect(parseCachedAccountUser("not json")).toBeNull();
expect(parseCachedAccountUser(JSON.stringify({ user: 42 }))).toBeNull();
expect(
parseCachedAccountUser(JSON.stringify({ user: makeUser() }))?.displayName,
).toBe("Beatrix");
});
it("classifies signed-out errors separately from transient failures", () => {
expect(
isSignedOutAccountError(new Error("No Cline account auth token found")),
).toBe(true);
expect(
isSignedOutAccountError(
new Error(
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
),
),
).toBe(true);
expect(isSignedOutAccountError(new Error("fetch failed"))).toBe(false);
});
it("fetches the signed-in user on mount and caches the identity", async () => {
invoke.mockResolvedValue(
makeUser({
organizations: [
{
active: true,
memberId: "member-1",
name: "Cline Bot Inc",
organizationId: "org-1",
roles: ["admin"],
},
],
}),
);
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(probeText("account-name")).toBe("Beatrix");
expect(probeText("account-org")).toBe("Cline Bot Inc");
});
expect(invoke).toHaveBeenCalledWith("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
expect(
parseCachedAccountUser(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
)?.email,
).toBe("beatrix@cline.bot");
});
it("clears the cached identity when the account is signed out", async () => {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user: makeUser() }),
);
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(probeText("account-name")).toBe("none");
});
expect(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
).toBeNull();
});
it("keeps the cached identity when the refresh fails transiently", async () => {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user: makeUser() }),
);
invoke.mockRejectedValue(
new Error("Desktop backend transport unavailable"),
);
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(invoke).toHaveBeenCalled();
});
expect(probeText("account-name")).toBe("Beatrix");
expect(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
).not.toBeNull();
});
});
@@ -0,0 +1,144 @@
"use client";
import type { ClineAccountOrganization, ClineAccountUser } from "@cline/core";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { desktopClient } from "@/lib/desktop-client";
export const ACCOUNT_IDENTITY_STORAGE_KEY = "cline.code.account-identity.v1";
const SIGNED_OUT_ERROR_MARKERS = [
"No Cline account auth token found",
"no longer valid",
];
type AccountContextValue = {
user: ClineAccountUser | null;
organizations: ClineAccountOrganization[];
activeOrganization: ClineAccountOrganization | null;
refreshAccount: () => Promise<void>;
};
const AccountContext = createContext<AccountContextValue>({
user: null,
organizations: [],
activeOrganization: null,
refreshAccount: async () => undefined,
});
export function parseCachedAccountUser(
raw: string | null,
): ClineAccountUser | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as { user?: ClineAccountUser | null };
const user = parsed?.user;
if (!user || typeof user !== "object") {
return null;
}
if (
typeof user.email !== "string" &&
typeof user.displayName !== "string"
) {
return null;
}
return user;
} catch {
return null;
}
}
function readCachedAccountUser(): ClineAccountUser | null {
if (typeof window === "undefined") {
return null;
}
try {
return parseCachedAccountUser(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
);
} catch {
return null;
}
}
function writeCachedAccountUser(user: ClineAccountUser | null): void {
if (typeof window === "undefined") {
return;
}
try {
if (user) {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user }),
);
} else {
window.localStorage.removeItem(ACCOUNT_IDENTITY_STORAGE_KEY);
}
} catch {
// Account identity still works for this session without the cache.
}
}
export function isSignedOutAccountError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return SIGNED_OUT_ERROR_MARKERS.some((marker) => message.includes(marker));
}
export function AccountProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ClineAccountUser | null>(null);
const refreshAccount = useCallback(async () => {
try {
const me = await desktopClient.invoke<ClineAccountUser>("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
setUser(me ?? null);
writeCachedAccountUser(me ?? null);
} catch (error) {
if (isSignedOutAccountError(error)) {
setUser(null);
writeCachedAccountUser(null);
}
// Transient failures (offline, sidecar restarting) keep the cached
// identity rather than flashing a signed-out state.
}
}, []);
useEffect(() => {
// Seed from the cached identity after mount so the signed-in name renders
// without waiting on the network fetch, which revalidates it right after.
// localStorage must not be read during the initial render: the server
// renders the signed-out state, and a differing first client render would
// be a hydration mismatch.
setUser((current) => current ?? readCachedAccountUser());
void refreshAccount();
}, [refreshAccount]);
const value = useMemo<AccountContextValue>(() => {
const organizations = user?.organizations ?? [];
return {
user,
organizations,
activeOrganization:
organizations.find((organization) => organization.active) ?? null,
refreshAccount,
};
}, [refreshAccount, user]);
return (
<AccountContext.Provider value={value}>{children}</AccountContext.Provider>
);
}
export function useAccount(): AccountContextValue {
return useContext(AccountContext);
}
@@ -3,6 +3,9 @@ import type { SessionHookEvent } from "@/lib/session-diff";
export type ProcessContext = {
workspaceRoot: string;
cwd: string;
homeDir?: string;
platform?: string;
appVersion?: string;
};
export type AgentChunkEvent = {
@@ -46,6 +46,7 @@ import type {
import {
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
registerHostHomeDirectory,
} from "@/lib/workspace-paths";
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
@@ -475,6 +476,9 @@ export function useChatSession() {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (ctx.homeDir) {
registerHostHomeDirectory(ctx.homeDir);
}
const rememberedWorkspace =
readWorkspaceSelectionFromWindow().lastWorkspace;
const validation = rememberedWorkspace
@@ -0,0 +1,55 @@
export const CHAT_BACKGROUND_STORAGE_KEY = "cline-chat-background";
const CHAT_BACKGROUND_CHANGE_EVENT = "cline:chat-background-changed";
/**
* Upper bound on the uploaded background. localStorage caps around ~5 MB per
* origin and base64 inflates bytes by ~33%, so keep the raw file under that
* (shared with the pet gif, so leave headroom for both).
*/
export const MAX_CHAT_BACKGROUND_BYTES = 3 * 1024 * 1024;
/** The custom chat background data URL, or null when none is set. */
export function readChatBackground(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(CHAT_BACKGROUND_STORAGE_KEY);
} catch {
return null;
}
}
/**
* Persist (or clear, when passed null) the chat background and notify listeners.
* Throws if the value exceeds the localStorage quota callers should validate
* size first and surface a friendly error.
*/
export function setChatBackground(dataUrl: string | null): void {
if (typeof window === "undefined") {
return;
}
if (dataUrl) {
window.localStorage.setItem(CHAT_BACKGROUND_STORAGE_KEY, dataUrl);
} else {
window.localStorage.removeItem(CHAT_BACKGROUND_STORAGE_KEY);
}
window.dispatchEvent(new CustomEvent(CHAT_BACKGROUND_CHANGE_EVENT));
}
/**
* Subscribe to background changes from this tab (settings edits) or another one
* (native `storage` event). Returns a cleanup function.
*/
export function subscribeChatBackground(listener: () => void): () => void {
if (typeof window === "undefined") {
return () => {};
}
const handle = () => listener();
window.addEventListener(CHAT_BACKGROUND_CHANGE_EVENT, handle);
window.addEventListener("storage", handle);
return () => {
window.removeEventListener(CHAT_BACKGROUND_CHANGE_EVENT, handle);
window.removeEventListener("storage", handle);
};
}
@@ -99,7 +99,7 @@ const RECONNECT_MAX_DELAY_MS = 4_000;
// Commands that should be routed to Tauri's native invoke bridge instead of
// the WebSocket transport — only applicable in the full Tauri app shell.
// In sidecar/web mode these commands are handled by the sidecar over WebSocket.
function isTauriAvailable(): boolean {
export function isTauriAvailable(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invoke, setTitle } = vi.hoisted(() => ({
invoke: vi.fn(),
setTitle: vi.fn(async () => undefined),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
isTauriAvailable: () => window.__TAURI_INTERNALS__ !== undefined,
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ setTitle }),
}));
async function importFresh() {
vi.resetModules();
return await import("./desktop-window-title");
}
beforeEach(() => {
invoke.mockReset();
setTitle.mockClear();
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
delete (window as any).__TAURI_INTERNALS__;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("desktop window title", () => {
it("builds a versioned title, falling back to the base title without a version", async () => {
const { buildDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
await importFresh();
expect(buildDesktopWindowTitle("1.2.3")).toBe(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
expect(buildDesktopWindowTitle(" 1.2.3 ")).toBe(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
expect(buildDesktopWindowTitle(undefined)).toBe(
DEFAULT_DESKTOP_WINDOW_TITLE,
);
expect(buildDesktopWindowTitle("")).toBe(DEFAULT_DESKTOP_WINDOW_TITLE);
});
it("does nothing outside the Tauri shell", async () => {
const { syncDesktopWindowTitle } = await importFresh();
await syncDesktopWindowTitle();
expect(invoke).not.toHaveBeenCalled();
expect(setTitle).not.toHaveBeenCalled();
});
it("sets the native window title once the sidecar reports a version", async () => {
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
(window as any).__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue({
workspaceRoot: "",
cwd: "",
appVersion: "1.2.3",
});
const { syncDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
await importFresh();
await syncDesktopWindowTitle();
expect(invoke).toHaveBeenCalledWith("get_process_context");
expect(setTitle).toHaveBeenCalledWith(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
});
it("leaves the title alone when the version is missing or the sidecar call fails", async () => {
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
(window as any).__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue({ workspaceRoot: "", cwd: "" });
const { syncDesktopWindowTitle } = await importFresh();
await syncDesktopWindowTitle();
expect(setTitle).not.toHaveBeenCalled();
invoke.mockRejectedValue(
new Error("Desktop backend transport unavailable"),
);
await syncDesktopWindowTitle();
expect(setTitle).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import type { ProcessContext } from "@/hooks/chat-session/types";
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
export const DEFAULT_DESKTOP_WINDOW_TITLE = "Cline Code";
export function buildDesktopWindowTitle(version: string | undefined): string {
const trimmed = version?.trim();
return trimmed
? `${DEFAULT_DESKTOP_WINDOW_TITLE} v${trimmed}`
: DEFAULT_DESKTOP_WINDOW_TITLE;
}
/**
* Tauri's window title is static in tauri.conf.json; append the running app
* version once the sidecar reports it. No-op outside the Tauri shell (e.g.
* sidecar/web dev mode), where there is no native window to retitle.
*/
export async function syncDesktopWindowTitle(): Promise<void> {
if (!isTauriAvailable()) {
return;
}
try {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (!ctx.appVersion?.trim()) {
return;
}
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setTitle(buildDesktopWindowTitle(ctx.appVersion));
} catch {
// Keep the default static title if the sidecar or window API is unavailable.
}
}
@@ -0,0 +1,62 @@
export const NYAN_PET_STORAGE_KEY = "cline-nyan-pet-gif";
const NYAN_PET_CHANGE_EVENT = "cline:nyan-pet-changed";
/** Bundled default pet, served from webview/public. */
export const DEFAULT_NYAN_PET_SRC = "/nyancat.gif";
/**
* Upper bound on an uploaded pet. localStorage caps around ~5 MB per origin and
* base64 inflates bytes by ~33%, so keep the raw file comfortably under that.
*/
export const MAX_NYAN_PET_BYTES = 3 * 1024 * 1024;
/** The custom pet data URL the user uploaded, or null when using the default. */
export function readStoredPetGif(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(NYAN_PET_STORAGE_KEY);
} catch {
return null;
}
}
/** The image source the pet should render — custom upload or bundled default. */
export function getNyanPetSrc(): string {
return readStoredPetGif() ?? DEFAULT_NYAN_PET_SRC;
}
/**
* Persist (or clear, when passed null) the custom pet and notify live listeners.
* Throws if the value exceeds the localStorage quota callers should validate
* size first and surface a friendly error.
*/
export function setStoredPetGif(dataUrl: string | null): void {
if (typeof window === "undefined") {
return;
}
if (dataUrl) {
window.localStorage.setItem(NYAN_PET_STORAGE_KEY, dataUrl);
} else {
window.localStorage.removeItem(NYAN_PET_STORAGE_KEY);
}
window.dispatchEvent(new CustomEvent(NYAN_PET_CHANGE_EVENT));
}
/**
* Subscribe to pet changes from this tab (settings edits) or another one
* (native `storage` event). Returns a cleanup function.
*/
export function subscribeNyanPet(listener: () => void): () => void {
if (typeof window === "undefined") {
return () => {};
}
const handle = () => listener();
window.addEventListener(NYAN_PET_CHANGE_EVENT, handle);
window.addEventListener("storage", handle);
return () => {
window.removeEventListener(NYAN_PET_CHANGE_EVENT, handle);
window.removeEventListener("storage", handle);
};
}
@@ -0,0 +1,57 @@
"use client";
import { isTauriAvailable } from "@/lib/desktop-client";
/** Whether we're running inside the Tauri desktop shell (vs plain web/dev). */
export function isTauri(): boolean {
return isTauriAvailable();
}
async function invokeTauri<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T | null> {
if (!isTauriAvailable()) {
return null;
}
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(command, args);
} catch (error) {
console.error(`pet-window: ${command} failed`, error);
return null;
}
}
/**
* The label of the Tauri window this document is running in ("main" or "pet"),
* or null when not running under Tauri. Used to decide whether to render the
* full app or just the floating pet.
*/
export async function getCurrentWindowLabel(): Promise<string | null> {
if (!isTauriAvailable()) {
return null;
}
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
return getCurrentWindow().label;
} catch {
return null;
}
}
/** Begin an OS-level drag of the pet window (called from the pet's webview). */
export const startPetDrag = () => invokeTauri("start_pet_drag");
/** Show the floating pet window and reassert its always-on-top presence. */
export const showPet = () => invokeTauri("show_pet");
/** Hide the floating pet window. */
export const hidePet = () => invokeTauri("hide_pet");
/** Whether the floating pet window is currently visible. */
export const isPetVisible = async (): Promise<boolean> =>
(await invokeTauri<boolean>("is_pet_visible")) ?? false;
/** Reopen (show + focus) the main app window after it was closed/hidden. */
export const showMainWindow = () => invokeTauri("show_main_window");
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
filterWorkspacePaths,
isExcludedWorkspacePath,
mergeWorkspacePaths,
normalizeWorkspacePath,
parseWorkspaceSelectionStorage,
registerHostHomeDirectory,
workspacePathsFromSessions,
} from "./workspace-paths";
@@ -45,6 +48,36 @@ describe("workspace paths", () => {
]);
});
it("keeps the first-seen order so earlier groups rank first", () => {
expect(
mergeWorkspacePaths(["/projects/zulu", "/projects/mike"], [
"/projects/alpha",
"/projects/zulu/",
]),
).toEqual(["/projects/zulu", "/projects/mike", "/projects/alpha"]);
});
it("orders the catalog by the most recent session in each workspace", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" },
{
workspaceRoot: "/projects/active",
startedAt: "2026-02-01T00:00:00Z",
endedAt: "2026-02-01T01:00:00Z",
},
{ workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" },
{ workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" },
{ workspaceRoot: "/projects/undated" },
]);
expect(paths).toEqual([
"/projects/old",
"/projects/mid",
"/projects/active",
"/projects/undated",
]);
});
it("builds the project catalog from every loaded history workspace", () => {
const sessions = Array.from({ length: 25 }, (_, index) => ({
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
@@ -74,4 +107,93 @@ describe("workspace paths", () => {
workspaces: [],
});
});
it("excludes .cline-internal paths from the workspace catalog", () => {
expect(
isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"),
).toBe(true);
expect(
isExcludedWorkspacePath(
"/Users/beatrix/.cline/plugins/_installed/git/github.com/example-plugin",
),
).toBe(true);
expect(
isExcludedWorkspacePath("C:\\Users\\Saoud\\.cline\\worktrees\\abc"),
).toBe(true);
});
describe("with a registered host home directory", () => {
afterEach(() => {
registerHostHomeDirectory("");
});
it("excludes a non-standard home and its Desktop but keeps projects inside them", () => {
registerHostHomeDirectory("/srv/homes/bea/");
expect(isExcludedWorkspacePath("/srv/homes/bea")).toBe(true);
expect(isExcludedWorkspacePath("/srv/homes/bea/Desktop")).toBe(true);
expect(isExcludedWorkspacePath("/srv/homes/bea/projects/app")).toBe(
false,
);
expect(isExcludedWorkspacePath("/srv/homes/beatrix")).toBe(false);
});
it("matches Windows homes case-insensitively", () => {
registerHostHomeDirectory("D:\\Homes\\Bea");
expect(isExcludedWorkspacePath("d:\\homes\\bea\\")).toBe(true);
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\Desktop")).toBe(true);
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\cline")).toBe(false);
});
});
it("excludes home and Desktop directories but keeps projects inside them", () => {
expect(isExcludedWorkspacePath("/Users/beatrix")).toBe(true);
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/")).toBe(true);
expect(isExcludedWorkspacePath("/home/beatrix")).toBe(true);
expect(isExcludedWorkspacePath("/root")).toBe(true);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud")).toBe(true);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Desktop")).toBe(true);
expect(isExcludedWorkspacePath("/Users/beatrix/dev/cline")).toBe(false);
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/my-app")).toBe(
false,
);
expect(isExcludedWorkspacePath("/home/beatrix/projects")).toBe(false);
expect(isExcludedWorkspacePath("/workspace/cline")).toBe(false);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Cline")).toBe(false);
});
it("filters excluded paths out of session-derived workspaces", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/app" },
{ workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" },
{ cwd: "/Users/beatrix/Desktop" },
{ cwd: "/Users/beatrix" },
{ cwd: "/projects/tool" },
]);
expect(paths).toEqual(["/projects/app", "/projects/tool"]);
});
it("scrubs excluded paths from the stored catalog while keeping the selection", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: [
"/projects/one",
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
"/Users/beatrix",
],
}),
),
).toEqual({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: ["/projects/one"],
});
expect(
filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]),
).toEqual(["/projects/one"]);
});
});
@@ -9,6 +9,8 @@ export type WorkspaceSelectionStorage = {
export type WorkspacePathSource = {
cwd?: string;
workspaceRoot?: string;
startedAt?: string;
endedAt?: string;
};
export function normalizeWorkspacePath(path: string): string {
@@ -21,6 +23,11 @@ export function normalizeWorkspacePath(path: string): string {
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
}
/**
* Dedupes paths across groups, keeping the first spelling seen and the
* first-seen position, so callers control the ranking (e.g. session recency)
* through argument order.
*/
export function mergeWorkspacePaths(
...pathGroups: ReadonlyArray<readonly string[]>
): string[] {
@@ -34,15 +41,98 @@ export function mergeWorkspacePaths(
}
}
}
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
return [...byNormalizedPath.values()];
}
const POSIX_HOME_OR_DESKTOP_PATTERN =
/^(?:\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/Desktop)?$/;
const WINDOWS_HOME_OR_DESKTOP_PATTERN =
/^[a-z]:[\\/]users[\\/][^\\/]+(?:[\\/]desktop)?$/i;
let hostHomePath = "";
/**
* The webview bundle has no usable `process.env`, so standard home locations
* are matched by the patterns above and the sidecar reports the real host
* home directory through `get_process_context` to cover non-standard ones.
*/
export function registerHostHomeDirectory(path: string): void {
hostHomePath = normalizeWorkspacePath(path);
}
function isRegisteredHomeOrDesktop(normalized: string): boolean {
if (!hostHomePath) {
return false;
}
if (normalized === hostHomePath) {
return true;
}
return (
normalized.startsWith(hostHomePath) &&
/^[\\/]desktop$/i.test(normalized.slice(hostHomePath.length))
);
}
/**
* Sessions can run anywhere (Cline-internal worktrees and plugin installs
* under `.cline`, or a shell's default cwd like the home or Desktop
* directory), but those locations are not projects to offer in the
* workspace catalog. The active workspace root is registered separately,
* so an explicitly opened directory still shows while selected.
*/
export function isExcludedWorkspacePath(path: string): boolean {
const normalized = normalizeWorkspacePath(path);
if (!normalized) {
return false;
}
if (normalized.split(/[\\/]/).includes(".cline")) {
return true;
}
return (
isRegisteredHomeOrDesktop(normalized) ||
POSIX_HOME_OR_DESKTOP_PATTERN.test(normalized) ||
WINDOWS_HOME_OR_DESKTOP_PATTERN.test(normalized)
);
}
export function filterWorkspacePaths(paths: readonly string[]): string[] {
return paths.filter((path) => !isExcludedWorkspacePath(path));
}
/**
* Workspaces with the most recent session activity come first; paths whose
* sessions carry no parseable timestamp fall back to alphabetical order at
* the end.
*/
export function workspacePathsFromSessions(
sessions: readonly WorkspacePathSource[],
): string[] {
return mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
);
const lastActivityByPath = new Map<string, number>();
for (const session of sessions) {
const normalized = normalizeWorkspacePath(
session.workspaceRoot || session.cwd || "",
);
if (!normalized) {
continue;
}
const activity = Date.parse(session.endedAt ?? session.startedAt ?? "");
if (Number.isNaN(activity)) {
continue;
}
const known = lastActivityByPath.get(normalized);
if (known === undefined || activity > known) {
lastActivityByPath.set(normalized, activity);
}
}
return filterWorkspacePaths(
mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
),
).sort((a, b) => {
const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0;
const bTime = lastActivityByPath.get(normalizeWorkspacePath(b)) ?? 0;
return bTime === aTime ? a.localeCompare(b) : bTime - aTime;
});
}
export function parseWorkspaceSelectionStorage(
@@ -67,7 +157,9 @@ export function parseWorkspaceSelectionStorage(
: [];
return {
lastWorkspace,
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(workspaces, [lastWorkspace]),
),
};
} catch {
return { lastWorkspace: "", workspaces: [] };
@@ -98,9 +190,9 @@ export function writeWorkspaceSelectionToWindow(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
lastWorkspace: value.lastWorkspace.trim(),
workspaces: mergeWorkspacePaths(value.workspaces, [
value.lastWorkspace,
]),
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(value.workspaces, [value.lastWorkspace]),
),
}),
);
} catch {
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+202
View File
@@ -0,0 +1,202 @@
# vscode-rollout — A/B loader for the SDK extension rollout
The VS Code Marketplace has no staged rollouts: publishing a version updates
every user. This package lets us ship the **SDK-based extension** (main's
`apps/vscode`, "next") to a percentage of users while everyone else keeps
running the **legacy extension** (the `legacy-extension` branch), inside a
single published VSIX.
## How it works
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
independently built extension bundles:
```
extension.js ← loader (this package)
package.json ← UNION of both bundles' manifests (generated, see below)
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
```
Per window, the loader:
1. Reads the cached cohort assignment from its own `globalState` keys —
synchronously, never from the network.
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
palette entries in the union manifest).
3. `require()`s exactly one bundle and calls its `activate()` with a
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
resolves its own webview build and assets without knowing it was relocated.
Storage properties pass through untouched: both bundles share the same
`~/.cline/data` + VS Code storage they used as standalone extensions.
4. After the selected bundle activates, evaluates the PostHog flags in the
background and caches the assignment **for the next window**. Flag changes
never flip a live window. A crash fallback skips this refresh so it cannot
overwrite the legacy pin.
If the next bundle throws during activation, the loader disposes whatever it
half-registered, pins this VSIX version back to legacy
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
event, and activates legacy — a crashed rollout self-heals without a
marketplace re-publish. A new version gets to try next again.
## Cohort rules
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
entire remote control surface: each background refresh caches exactly what
the flag says for the machine's next window. Dialing the percentage up
promotes; dialing it down demotes on the next reload — the emergency lever
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
created on the SDK bundle are stored as SDK sessions the legacy bundle
doesn't list (they reappear on re-promotion — nothing is deleted), and
credentials rotated on next may require a re-login on legacy.
- **The flag must stay a boolean flag.** The loader only promotes on a
literal `true` from `/decide` — a multivariate variant, number, or anything
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
convert it to multivariate.
- The flag is evaluated against the same PostHog distinct id the extension's
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
so cohort membership is correlatable with telemetry. Flag evaluation is
always on (matching `FeatureFlagsService`); the loader's own
`extension.rollout.loader_decision` event respects the user's telemetry
opt-out and VS Code's global telemetry switch.
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
user setting (`"auto" | "next" | "legacy"`, editable straight from
settings.json) forces a bundle for anyone — users in a pinch, or us
debugging — beating the remote assignment both ways. Applies on window
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
local dev and e2e and beats even the setting. Both are reported as
`override` on the loader event so overridden machines don't pollute
cohort comparisons.
- **Crash pinning is local, not remote.** If the next bundle throws during
activation, the loader falls back to legacy in the same window and pins
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
a new release gets to try next again. This safety net is independent of the
flag.
## The union manifest
`package.json` contributions are static — VS Code reads them before any code
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
regenerates it at stitch time from both branches' real manifests:
- Contributions declared by both bundles pass through untouched.
- Menu entries / keybindings declared by only one get `when` AND-ed with
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
bundle didn't register (and shared buttons that moved position don't render
twice).
- Commands exclusive to one bundle are hidden from the other cohort's command
palette.
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
**must be identical** in both manifests — they can't be safely gated at
runtime, so divergence fails the build. Keep these static contributions in
sync between the branches. `engines` may diverge: the union takes the newer
requirement (which necessarily satisfies the older one).
Because the manifest is regenerated from both branches on every build,
contribution drift between the branches can't ship silently — it either merges
cleanly or the stitch fails.
## Building locally
```bash
# 1. build both bundles (their own toolchains)
cd apps/vscode && bun run package # next
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
# 2. build the loader + stitch + package
cd apps/vscode-rollout
bun run build # dev build; CI uses build:production with the PostHog key
node scripts/stitch.mjs \
--next ../vscode --legacy <legacy worktree>/apps/vscode \
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
```
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
legacy packaging workflows. This workflow supplies only the existing PostHog
project-key inputs; it does not declare a SendGrid credential. Identify the
exact matching string in production staging output before changing or
broadening the exemption.
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
## Nightly channel
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
nightly identity — the same mutation the standalone nightly always applied
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
truth), so nightly can be installed alongside stable:
| | stable | nightly |
|---|---|---|
| manifest `name` | `claude-dev` | `cline-nightly` |
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
The loader derives the namespace from its own `packageJSON.name` at runtime
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
manifest's name — no build flags involved. Nightly builds also show a
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
the installable `.vsix` without publishing or tagging. The publish job is
intentionally restricted to `main` by both the workflow and the
`PublishNightly` environment's deployment-branch policy.
### Telemetry events
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
activated bundle's own telemetry via its `reportRolloutActivation` export;
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
actual bundle, fallback flag, error details on fallback. Every other event
from a rollout build carries `extension_variant` as a common property.
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
loader-side metadata the bundle event can't know — override source, launch
cadence, loader version, `extension_name` (nightly vs stable) — and the only
signal when BOTH bundles fail (`double_failure: true`).
## Rollout runbook
Until the stable combined VSIX ships, the flag governs **nightly installs
only** — dialing it is safe for production users and is the lever for moving
nightly dogfooders onto next.
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
plain boolean release flag with a percentage rollout, starting at **0%**.
(There is deliberately no kill-switch flag — the assignment is two-way, so
0% *is* the kill switch.)
2. Publish the combined VSIX (version above every previously published one).
With the rollout at 0% this release is behaviorally identical to legacy for
everyone — it only validates the loader plumbing in the wild. Watch
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
each machine's next window reload after its flag refresh, so propagation
speed is bounded by how often people reload windows — watch the
`ms_since_last_activation` distribution on loader events to see real
uptake lag before deciding the next step, and compare cohorts by the
`bundle` property.
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
on their next reload). Demoted machines keep settings and creds; tasks
created on the SDK bundle reappear when re-promoted. Ship the fix as a
higher version, then dial back up. Machines whose next bundle *crashed*
are additionally version-pinned to legacy locally, independent of the flag.
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
extension build and delete this package.
## Version numbering
The combined VSIX owns the marketplace version line and must always exceed the
last version published from either branch (legacy stable was 4.0.x → start at
4.1.0). The bundles' own `package.json` versions ride along inside their
subdirectories for provenance; the loader reports the combined version as
`loader_version`.
+27
View File
@@ -0,0 +1,27 @@
import esbuild from "esbuild";
const production = process.argv.includes("--production");
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
// loader skips all PostHog calls (everyone stays on legacy).
const define = {};
if (process.env.TELEMETRY_SERVICE_API_KEY) {
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
process.env.TELEMETRY_SERVICE_API_KEY,
);
}
await esbuild.build({
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
platform: "node",
format: "cjs",
target: "node18",
external: ["vscode"],
minify: production,
sourcemap: !production,
define,
logLevel: "info",
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@cline/vscode-rollout",
"private": true,
"version": "0.1.0",
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
"type": "module",
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"typecheck": "tsc --noEmit",
"test": "bun test src scripts",
"stitch": "node scripts/stitch.mjs"
},
"devDependencies": {
"@types/node": "20.x",
"@types/vscode": "1.84.0",
"esbuild": "^0.25.0",
"node-machine-id": "^1.1.12",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,437 @@
/**
* Generate the combined VSIX's package.json as the UNION of the two bundles'
* manifests, regenerated from both branches' actual package.json files at
* stitch time so contribution drift between branches can't ship silently.
*
* Rules:
* - Identity/top-level fields come from the next (main) manifest.
* - `main` points at the loader; `version` comes from the release input.
* - commands / menus / keybindings / activationEvents / icons are unioned.
* Menu entries and keybindings present in only ONE manifest get their
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
* loader before activation; prefix follows the manifest identity see
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
* its bundle doesn't register and shared buttons that moved position
* don't show up twice. Commands exclusive to one bundle are likewise hidden
* from the other cohort's command palette.
* - views / viewsContainers / configuration / engines MUST be
* identical in both manifests they can't be safely gated at runtime, so
* divergence is a hard error.
*
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
*/
import { deepStrictEqual } from "node:assert";
import { readFileSync, writeFileSync } from "node:fs";
export function generateManifest(nextPkg, legacyPkg, version) {
for (const field of ["name", "publisher", "main"]) {
if (nextPkg[field] !== legacyPkg[field]) {
throw new Error(
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
);
}
}
for (const field of ["views", "viewsContainers", "configuration"]) {
try {
deepStrictEqual(
nextPkg.contributes?.[field],
legacyPkg.contributes?.[field],
);
} catch {
throw new Error(
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
);
}
}
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
assertWalkthroughsCompatible(
nextPkg.contributes?.walkthroughs,
legacyPkg.contributes?.walkthroughs,
);
// The nightly packaging rewrites the whole `cline.*` ID namespace to
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
// injected setting must follow the manifest's identity. Keep in sync with
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
const nextGate = `${prefix}.sdkBundle`;
const legacyGate = `!${nextGate}`;
const nc = nextPkg.contributes ?? {};
const lc = legacyPkg.contributes ?? {};
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
hideExclusiveCommandsFromPalette(
menus,
nc.commands,
lc.commands,
nextGate,
legacyGate,
);
const manifest = {
name: nextPkg.name,
displayName: nextPkg.displayName,
description: nextPkg.description,
version,
icon: nextPkg.icon,
engines,
author: nextPkg.author,
license: nextPkg.license,
publisher: nextPkg.publisher,
repository: nextPkg.repository,
homepage: nextPkg.homepage,
categories: nextPkg.categories,
keywords: nextPkg.keywords,
activationEvents: unionPrimitive(
nextPkg.activationEvents,
legacyPkg.activationEvents,
),
main: "./extension.js",
contributes: {
viewsContainers: nc.viewsContainers,
views: nc.views,
commands: unionBy(
[...(nc.commands ?? []), ...(lc.commands ?? [])],
(c) => c.command,
),
keybindings: unionGated(
nc.keybindings,
lc.keybindings,
nextGate,
legacyGate,
),
menus,
icons: unionIcons(nc.icons, lc.icons),
configuration: injectLoaderConfiguration(nc.configuration, prefix),
walkthroughs: nc.walkthroughs,
},
scripts: {},
};
assertSuperset(manifest, nextPkg, "next", nextGate);
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
return manifest;
}
/**
* The loader's own user-visible escape hatch, keyed by the manifest identity.
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
* settingSection/SETTING_BUNDLE_OVERRIDE keep the key and values in sync).
* Injected after the configuration-equality invariant so it can't mask real
* drift between the bundles.
*/
function loaderSettings(prefix) {
return {
[`${prefix}.rollout.bundleOverride`]: {
type: "string",
enum: ["auto", "next", "legacy"],
enumDescriptions: [
"Follow the remote rollout assignment.",
"Force the new (SDK-based) extension.",
"Force the previous (legacy) extension.",
],
default: "auto",
scope: "application",
markdownDescription:
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
},
};
}
function injectLoaderConfiguration(configuration, prefix) {
const properties = { ...(configuration?.properties ?? {}) };
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
if (properties[key]) {
throw new Error(
`bundle manifests must not declare loader-owned setting ${key}`,
);
}
properties[key] = schema;
}
return { title: "Cline", ...(configuration ?? {}), properties };
}
/**
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
* always comes from the next checkout so requiring byte-identical manifests
* here would brick releases over copy tweaks while protecting nothing. Only
* STRUCTURE must match (walkthrough/step ids, media paths, completion events
* the parts code and the manifest reference); when titles/descriptions
* diverge, next's copy ships for everyone and the build says so.
*/
function assertWalkthroughsCompatible(next = [], legacy = []) {
const structure = (walkthroughs) =>
walkthroughs.map((walkthrough) => ({
id: walkthrough.id,
steps: (walkthrough.steps ?? []).map((step) => ({
id: step.id,
media: step.media,
completionEvents: step.completionEvents,
when: step.when,
})),
}));
try {
deepStrictEqual(structure(next), structure(legacy));
} catch {
throw new Error(
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
);
}
try {
deepStrictEqual(next, legacy);
} catch {
console.warn(
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
);
}
}
/**
* Engines can safely diverge in ONE direction: the union requires whichever
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
* older requirement too. (main routinely bumps the VS Code engine ahead of the
* legacy branch an equality assertion here would brick every combined build
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
* guessing.
*/
function unionEngines(nextEngines = {}, legacyEngines = {}) {
const union = {};
for (const key of new Set([
...Object.keys(nextEngines),
...Object.keys(legacyEngines),
])) {
const a = nextEngines[key];
const b = legacyEngines[key];
if (a === undefined || b === undefined || a === b) {
union[key] = a ?? b;
continue;
}
const minimum = (range) => {
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
return match?.[1];
};
const [minA, minB] = [minimum(a), minimum(b)];
if (!minA || !minB) {
throw new Error(
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
);
}
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
console.warn(
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
);
}
return union;
}
/** Compare dotted numeric versions. */
function compareDotted(a, b) {
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function unionPrimitive(a = [], b = []) {
return [...new Set([...a, ...b])];
}
/** Union keeping first occurrence per key (next wins on shared ids). */
function unionBy(items, keyFn) {
const seen = new Map();
for (const item of items) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.set(key, item);
}
}
return [...seen.values()];
}
function sortKeysDeep(value) {
if (Array.isArray(value)) {
return value.map(sortKeysDeep);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep(value[key])]),
);
}
return value;
}
function stableJson(value) {
return JSON.stringify(sortKeysDeep(value));
}
function gateWhen(entry, gate) {
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
}
/**
* Union two entry lists (menu entries or keybindings): entries declared by
* both bundles pass through untouched; entries declared by only one get their
* `when` AND-ed with that bundle's cohort gate.
*/
function unionGated(
nextEntries = [],
legacyEntries = [],
nextGate,
legacyGate,
) {
const nextSet = new Set(nextEntries.map(stableJson));
const legacySet = new Set(legacyEntries.map(stableJson));
const entries = [];
for (const entry of nextEntries) {
entries.push(
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
);
}
for (const entry of legacyEntries) {
if (!nextSet.has(stableJson(entry))) {
entries.push(gateWhen(entry, legacyGate));
}
}
return entries;
}
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
const menus = {};
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
menus[location] = unionGated(
a[location],
b[location],
nextGate,
legacyGate,
);
}
return menus;
}
/**
* A command declared by only one bundle would surface in the other cohort's
* command palette with no registered handler ("command not found" on run).
* Hide it there unless that bundle's own manifest already constrains it.
*/
function hideExclusiveCommandsFromPalette(
menus,
nextCommands = [],
legacyCommands = [],
nextGate,
legacyGate,
) {
const nextIds = new Set(nextCommands.map((c) => c.command));
const legacyIds = new Set(legacyCommands.map((c) => c.command));
const palette = menus.commandPalette ?? (menus.commandPalette = []);
const alreadyListed = new Set(palette.map((e) => e.command));
for (const id of nextIds) {
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: nextGate });
}
}
for (const id of legacyIds) {
if (!nextIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: legacyGate });
}
}
}
function unionIcons(a = {}, b = {}) {
const icons = { ...b, ...a };
for (const id of Object.keys(icons)) {
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
throw new Error(
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
);
}
}
return icons;
}
/**
* Every command/keybinding/menu entry/activation event a bundle declares must
* survive the union, either verbatim or with its `when` AND-ed with that
* bundle's cohort gate.
*/
function assertSuperset(manifest, sourcePkg, label, gate) {
const missing = [];
const commandIds = new Set(
manifest.contributes.commands.map((c) => c.command),
);
for (const cmd of sourcePkg.contributes?.commands ?? []) {
if (!commandIds.has(cmd.command)) {
missing.push(`command ${cmd.command}`);
}
}
for (const event of sourcePkg.activationEvents ?? []) {
if (!manifest.activationEvents.includes(event)) {
missing.push(`activationEvent ${event}`);
}
}
const presentOrGated = (unionEntries, entry) => {
const set = new Set((unionEntries ?? []).map(stableJson));
return (
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
);
};
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
missing.push(`keybinding ${kb.command}`);
}
}
for (const [location, entries] of Object.entries(
sourcePkg.contributes?.menus ?? {},
)) {
for (const entry of entries) {
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
missing.push(
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
);
}
}
}
if (missing.length > 0) {
throw new Error(
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
);
}
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { next, legacy, version, out } = parseArgs(process.argv);
if (!next || !legacy || !version) {
console.error(
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
);
process.exit(1);
}
const manifest = generateManifest(
JSON.parse(readFileSync(next, "utf8")),
JSON.parse(readFileSync(legacy, "utf8")),
version,
);
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
if (out) {
writeFileSync(out, json);
console.log(`wrote ${out}`);
} else {
process.stdout.write(json);
}
}
@@ -0,0 +1,382 @@
import { describe, expect, it } from "bun:test";
import { generateManifest } from "./gen-manifest.mjs";
const shared = {
name: "claude-dev",
publisher: "saoudrizwan",
main: "./dist/extension.js",
engines: { vscode: "^1.84.0" },
displayName: "Cline",
};
function pkg(overrides) {
return {
...shared,
activationEvents: ["onStartupFinished"],
contributes: {
viewsContainers: {
activitybar: [{ id: "c", title: "Cline", icon: "assets/icon.svg" }],
},
views: { c: [{ type: "webview", id: "claude-dev.SidebarProvider" }] },
commands: [],
keybindings: [],
menus: {},
icons: {},
...overrides.contributes,
},
...Object.fromEntries(
Object.entries(overrides).filter(([k]) => k !== "contributes"),
),
};
}
describe("generateManifest", () => {
it("unions commands, menus, keybindings and activation events", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.a", title: "A" },
{ command: "cline.shared", title: "S" },
],
menus: { "view/title": [{ command: "cline.a", when: "x" }] },
},
});
const legacy = pkg({
activationEvents: ["onStartupFinished", "workspaceContains:evals.env"],
contributes: {
commands: [
{ command: "cline.b", title: "B" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [{ command: "cline.b", when: "y" }],
"comments/commentThread/title": [{ command: "cline.b" }],
},
keybindings: [{ command: "cline.b", key: "ctrl+k" }],
},
});
const manifest = generateManifest(next, legacy, "4.1.0");
expect(manifest.version).toBe("4.1.0");
expect(manifest.main).toBe("./extension.js");
expect(manifest.contributes.commands.map((c) => c.command).sort()).toEqual([
"cline.a",
"cline.b",
"cline.shared",
]);
expect(manifest.contributes.menus["view/title"]).toHaveLength(2);
expect(
manifest.contributes.menus["comments/commentThread/title"],
).toHaveLength(1);
expect(manifest.contributes.keybindings).toHaveLength(1);
expect(manifest.activationEvents).toContain("workspaceContains:evals.env");
});
it("gates cohort-exclusive menu entries and keybindings on the context key", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.a", title: "A" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [
{ command: "cline.a", when: "x" },
{ command: "cline.shared", when: "v" },
],
},
},
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.b", title: "B" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [
{ command: "cline.b", when: "y" },
{ command: "cline.shared", when: "v" },
],
},
keybindings: [{ command: "cline.b", key: "ctrl+k", when: "focus" }],
},
});
const manifest = generateManifest(next, legacy, "4.1.0");
const viewTitle = manifest.contributes.menus["view/title"];
expect(viewTitle.find((e) => e.command === "cline.a").when).toBe(
"(x) && cline.sdkBundle",
);
expect(viewTitle.find((e) => e.command === "cline.b").when).toBe(
"(y) && !cline.sdkBundle",
);
expect(viewTitle.find((e) => e.command === "cline.shared").when).toBe("v");
expect(manifest.contributes.keybindings[0].when).toBe(
"(focus) && !cline.sdkBundle",
);
});
it("hides cohort-exclusive commands from the other cohort's palette", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.nextOnly", title: "N" },
{ command: "cline.shared", title: "S" },
],
},
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.legacyOnly", title: "L" },
{ command: "cline.shared", title: "S" },
],
},
});
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
.commandPalette;
expect(palette).toContainEqual({
command: "cline.nextOnly",
when: "cline.sdkBundle",
});
expect(palette).toContainEqual({
command: "cline.legacyOnly",
when: "!cline.sdkBundle",
});
expect(palette.find((e) => e.command === "cline.shared")).toBeUndefined();
});
it("leaves commands alone when a bundle already declares a palette entry for them", () => {
const next = pkg({
contributes: { commands: [{ command: "cline.shared", title: "S" }] },
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.hidden", title: "H" },
{ command: "cline.shared", title: "S" },
],
menus: { commandPalette: [{ command: "cline.hidden", when: "false" }] },
},
});
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
.commandPalette;
expect(palette.filter((e) => e.command === "cline.hidden")).toEqual([
{ command: "cline.hidden", when: "(false) && !cline.sdkBundle" },
]);
});
it("dedupes structurally identical menu entries", () => {
const entry = { command: "cline.a", when: "view == cline" };
const next = pkg({
contributes: {
commands: [{ command: "cline.a", title: "A" }],
menus: { "view/title": [entry] },
},
});
const legacy = pkg({
contributes: {
commands: [{ command: "cline.a", title: "A" }],
menus: { "view/title": [{ ...entry }] },
},
});
expect(
generateManifest(next, legacy, "1.0.0").contributes.menus["view/title"],
).toHaveLength(1);
});
it("rejects diverged views/viewsContainers", () => {
const next = pkg({});
const legacy = pkg({
contributes: { views: { c: [{ type: "webview", id: "other" }] } },
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/views/);
});
it("rejects structurally diverged walkthroughs", () => {
const walkthrough = (stepId, media) => ({
contributes: {
walkthroughs: [
{
id: "ClineWalkthrough",
title: "Meet Cline",
steps: [{ id: stepId, title: "Start here", media }],
},
],
},
});
expect(() =>
generateManifest(
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
pkg(walkthrough("hello", { markdown: "walkthrough/step1.md" })),
"1.0.0",
),
).toThrow(/walkthroughs diverged structurally/);
expect(() =>
generateManifest(
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
pkg(walkthrough("welcome", { markdown: "walkthrough/other.md" })),
"1.0.0",
),
).toThrow(/walkthroughs diverged structurally/);
});
it("tolerates copy-only walkthrough divergence, shipping next's text", () => {
const walkthrough = (description) => ({
contributes: {
walkthroughs: [
{
id: "ClineWalkthrough",
title: "Meet Cline",
steps: [
{
id: "welcome",
title: "Start here",
description,
media: { markdown: "walkthrough/step1.md" },
},
],
},
],
},
});
const manifest = generateManifest(
pkg(walkthrough("Connect via MCP.")),
pkg(walkthrough("Discover the MCP Marketplace.")),
"1.0.0",
);
expect(manifest.contributes.walkthroughs[0].steps[0].description).toBe(
"Connect via MCP.",
);
});
it("rejects diverged configuration", () => {
const next = pkg({
contributes: {
configuration: {
title: "Cline",
properties: {
"cline.enabled": { type: "boolean", default: false },
},
},
},
});
const legacy = pkg({
contributes: {
configuration: {
title: "Cline",
properties: {
"cline.enabled": { type: "boolean", default: 0 },
},
},
},
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(
/contributes\.configuration diverged/,
);
});
it("injects the loader-owned bundleOverride setting into the union", () => {
const manifest = generateManifest(pkg({}), pkg({}), "4.1.0");
const prop =
manifest.contributes.configuration.properties[
"cline.rollout.bundleOverride"
];
expect(prop).toBeDefined();
expect(prop.enum).toEqual(["auto", "next", "legacy"]);
expect(prop.default).toBe("auto");
expect(prop.scope).toBe("application");
});
it("rejects bundles that declare the loader-owned setting themselves", () => {
const withClash = {
contributes: {
configuration: {
title: "Cline",
properties: { "cline.rollout.bundleOverride": { type: "string" } },
},
},
};
expect(() =>
generateManifest(pkg(withClash), pkg(withClash), "4.1.0"),
).toThrow(/loader-owned setting/);
});
it("derives gates and the injected setting from the nightly identity", () => {
const nightly = (overrides) => ({
...pkg(overrides),
name: "cline-nightly",
displayName: "Cline (Nightly)",
});
const next = nightly({
contributes: {
commands: [{ command: "cline-nightly.nextOnly", title: "N" }],
menus: {
"view/title": [{ command: "cline-nightly.nextOnly", when: "x" }],
},
},
});
const legacy = nightly({
contributes: {
commands: [{ command: "cline-nightly.legacyOnly", title: "L" }],
keybindings: [{ command: "cline-nightly.legacyOnly", key: "ctrl+k" }],
},
});
const manifest = generateManifest(next, legacy, "4.0.1752600000");
expect(manifest.name).toBe("cline-nightly");
expect(manifest.contributes.menus["view/title"][0].when).toBe(
"(x) && cline-nightly.sdkBundle",
);
expect(manifest.contributes.keybindings[0].when).toBe(
"!cline-nightly.sdkBundle",
);
expect(manifest.contributes.menus.commandPalette).toContainEqual({
command: "cline-nightly.legacyOnly",
when: "!cline-nightly.sdkBundle",
});
const properties = manifest.contributes.configuration.properties;
expect(properties["cline-nightly.rollout.bundleOverride"]).toBeDefined();
expect(properties["cline.rollout.bundleOverride"]).toBeUndefined();
});
it("unions diverged engines to the newer requirement (either direction)", () => {
const olderLegacy = { ...pkg({}), engines: { vscode: "^1.74.0" } };
expect(generateManifest(pkg({}), olderLegacy, "1.0.0").engines).toEqual({
vscode: "^1.84.0",
});
const newerLegacy = { ...pkg({}), engines: { vscode: "^1.101.0" } };
expect(generateManifest(pkg({}), newerLegacy, "1.0.0").engines).toEqual({
vscode: "^1.101.0",
});
});
it("rejects diverged engines it cannot compare", () => {
const legacy = { ...pkg({}), engines: { vscode: ">=1.84.0 <2.0.0" } };
expect(() => generateManifest(pkg({}), legacy, "1.0.0")).toThrow(
/uncomparable/,
);
});
it("rejects conflicting icon definitions", () => {
const next = pkg({
contributes: {
icons: {
"cline-logo": {
description: "d",
default: { fontPath: "a.woff", fontCharacter: "\\E900" },
},
},
},
});
const legacy = pkg({
contributes: {
icons: {
"cline-logo": {
description: "d",
default: { fontPath: "b.woff", fontCharacter: "\\E900" },
},
},
},
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/icons/);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Rewrite an apps/vscode package.json to the nightly identity, in place.
*
* Reproduces updatePackageJson() from apps/vscode/scripts/publish-nightly.mjs
* (the same script exists on BOTH main and legacy-extension those copies are
* the source of truth for the mutation; if they change, change this too):
* - textual rewrites: "claude-dev" -> "cline-nightly" everywhere, and every
* `"cline.` ID prefix -> `"cline-nightly.` (commands, settings, view IDs,
* when-clauses that START with the key mid-string references like
* `config.cline.x` are NOT rewritten, same as the standalone nightly)
* - name / displayName / activity bar title / version
*
* Differences from publish-nightly.mjs, on purpose:
* - the version is an explicit ARGUMENT, not computed here: the combined
* VSIX applies ONE version to the next bundle, the legacy bundle, and the
* union manifest, so gen-manifest's identity-equality assertions hold.
* - no backup/restore, README swapping, or workspace-self-link reconciling:
* this runs against a disposable CI checkout, BEFORE the bundle build and
* never followed by vsce in that checkout (vsce only runs in the stitched
* staging dir with --no-dependencies).
*
* Run it AFTER dependency install (the workspace self-link resolution keys off
* the original package name) and BEFORE the bundle's package build.
*
* Usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export const NIGHTLY_NAME = "cline-nightly";
export const NIGHTLY_DISPLAY_NAME = "Cline (Nightly)";
export function nightlifyPackageJson(rawContent, version) {
if (!version) {
throw new Error("version is required");
}
const content = rawContent
.replaceAll("claude-dev", NIGHTLY_NAME)
.replaceAll('"cline.', `"${NIGHTLY_NAME}.`);
const pkg = JSON.parse(content);
pkg.name = NIGHTLY_NAME;
pkg.displayName = NIGHTLY_DISPLAY_NAME;
pkg.version = version;
// publish-nightly.mjs assigns `.title` on the activitybar value directly,
// which is a silent no-op on the real manifest (activitybar is an ARRAY —
// JSON.stringify drops non-index properties). Retitle the actual entries.
const activitybar = pkg.contributes?.viewsContainers?.activitybar;
for (const container of Array.isArray(activitybar) ? activitybar : []) {
container.title = NIGHTLY_DISPLAY_NAME;
}
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
const { dir, version } = parseArgs(process.argv);
if (!dir || !version) {
console.error(
"usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>",
);
process.exit(1);
}
const packageJsonPath = path.join(dir, "package.json");
const before = readFileSync(packageJsonPath, "utf8");
const beforeName = JSON.parse(before).name;
writeFileSync(packageJsonPath, nightlifyPackageJson(before, version));
console.log(
`nightlified ${packageJsonPath}: ${beforeName} -> ${NIGHTLY_NAME}@${version}`,
);
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test";
import { nightlifyPackageJson } from "./nightlify.mjs";
const fixture = {
name: "claude-dev",
displayName: "Cline",
publisher: "saoudrizwan",
version: "4.0.0",
main: "./dist/extension.js",
contributes: {
viewsContainers: {
activitybar: [
{
id: "claude-dev-ActivityBar",
title: "Cline",
icon: "assets/icon.svg",
},
],
},
views: {
"claude-dev-ActivityBar": [
{ type: "webview", id: "claude-dev.SidebarProvider" },
],
},
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
keybindings: [{ command: "cline.addToChat", key: "ctrl+'" }],
menus: {
"view/title": [
{
command: "cline.plusButtonClicked",
when: "view == claude-dev.SidebarProvider",
},
// Mid-string references are NOT rewritten — a known limitation
// shared with the standalone nightly's publish-nightly.mjs.
{ command: "cline.addToChat", when: "config.cline.enableExtras" },
],
},
configuration: {
title: "Cline",
properties: { "cline.enableExtras": { type: "boolean" } },
},
},
};
describe("nightlifyPackageJson", () => {
const pkg = JSON.parse(
nightlifyPackageJson(JSON.stringify(fixture, null, "\t"), "4.0.1752600000"),
);
it("sets the nightly identity and the supplied version", () => {
expect(pkg.name).toBe("cline-nightly");
expect(pkg.displayName).toBe("Cline (Nightly)");
expect(pkg.version).toBe("4.0.1752600000");
expect(pkg.publisher).toBe("saoudrizwan");
});
it("rewrites claude-dev IDs and the cline.* namespace", () => {
expect(pkg.contributes.viewsContainers.activitybar[0].id).toBe(
"cline-nightly-ActivityBar",
);
expect(pkg.contributes.viewsContainers.activitybar[0].title).toBe(
"Cline (Nightly)",
);
expect(Object.keys(pkg.contributes.views)).toEqual([
"cline-nightly-ActivityBar",
]);
expect(pkg.contributes.views["cline-nightly-ActivityBar"][0].id).toBe(
"cline-nightly.SidebarProvider",
);
expect(pkg.contributes.commands[0].command).toBe(
"cline-nightly.plusButtonClicked",
);
expect(pkg.contributes.keybindings[0].command).toBe(
"cline-nightly.addToChat",
);
expect(Object.keys(pkg.contributes.configuration.properties)).toEqual([
"cline-nightly.enableExtras",
]);
});
it("rewrites when-clauses that start with a rewritten ID, but not mid-string references", () => {
const [gated, midString] = pkg.contributes.menus["view/title"];
expect(gated.when).toBe("view == cline-nightly.SidebarProvider");
// Documented limitation: `config.cline.` does not match the `"cline.`
// pattern, so it survives unrewritten (matches publish-nightly.mjs).
expect(midString.when).toBe("config.cline.enableExtras");
});
it("requires a version", () => {
expect(() => nightlifyPackageJson("{}", undefined)).toThrow(/version/);
});
});
@@ -0,0 +1,55 @@
/**
* Stamp the combined VSIX's version into a bundle checkout's package.json,
* in place, BEFORE that bundle builds.
*
* Why: the union manifest's version (what the Marketplace and auto-update
* see) is supplied at stitch time, but each bundle's runtime reads its OWN
* package.json the About tab and every telemetry event's extension_version
* come from there. Without this stamp the stable combined VSIX would report
* three different versions (union input, main's base version, legacy's base
* version) depending on where you look, which turns user bug reports into
* archaeology. The nightly path gets the same alignment via nightlify.mjs
* (which also rewrites identity); this script is the identity-preserving
* version-only equivalent for the stable channel.
*
* Usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export function setPackageVersion(rawContent, version) {
if (!version) {
throw new Error("version is required");
}
const pkg = JSON.parse(rawContent);
pkg.version = version;
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
const { dir, version } = parseArgs(process.argv);
if (!dir || !version) {
console.error(
"usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>",
);
process.exit(1);
}
const packageJsonPath = path.join(dir, "package.json");
const before = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
writeFileSync(
packageJsonPath,
setPackageVersion(readFileSync(packageJsonPath, "utf8"), version),
);
console.log(`set ${packageJsonPath} version: ${before} -> ${version}`);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "bun:test";
import { setPackageVersion } from "./set-version.mjs";
const fixture = JSON.stringify(
{
name: "claude-dev",
displayName: "Cline",
publisher: "saoudrizwan",
version: "4.0.0",
contributes: {
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
},
},
null,
"\t",
);
describe("setPackageVersion", () => {
it("stamps the version and touches nothing else", () => {
const pkg = JSON.parse(setPackageVersion(fixture, "4.1.0"));
expect(pkg.version).toBe("4.1.0");
expect(pkg.name).toBe("claude-dev");
expect(pkg.displayName).toBe("Cline");
expect(pkg.publisher).toBe("saoudrizwan");
expect(pkg.contributes.commands[0].command).toBe(
"cline.plusButtonClicked",
);
});
it("requires a version", () => {
expect(() => setPackageVersion(fixture, undefined)).toThrow(/version/);
expect(() => setPackageVersion(fixture, "")).toThrow(/version/);
});
});
@@ -0,0 +1,671 @@
/**
* Node-level smoke test for the built loader against a staged VSIX directory.
* No real VS Code: `vscode` is stubbed just enough for the loader itself, and
* the staging dir's next/legacy bundles are swapped for tiny recorders. Verifies
* the loader's end-to-end behavior in a real require() environment:
* 1. default (no cached cohort) -> activates legacy
* 2. cached cohort "next" -> activates next, scoped context paths
* 3. the flag refresh caches a TWO-WAY assignment for the next window
* (rollout on promotes, rollout off demotes a cached "next")
* 4. CLINE_BUNDLE_OVERRIDE / the cline.rollout.bundleOverride setting
* force a bundle in either direction
* 5. next activation throws -> disposes partial registrations, falls
* back to legacy, pins version, and
* skips the cohort refresh
* 6. the activated bundle's reportRolloutActivation export receives the
* authoritative attempted/actual/fallback record (and its absence is
* tolerated); the loader's own loader_decision capture fires exactly
* once per window
* 7. the nightly identity (manifest name cline-nightly) switches the
* setting section + context key namespace and shows the status bar
* bundle indicator
* 8. both bundles throwing surfaces the failure and captures a
* double_failure loader event
*
* Usage: node smoke-loader.mjs <staging-dir>
* Copies the staging dir to a temp sandbox; the input is never modified.
*/
import assert from "node:assert";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import Module from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
const staging = process.argv[2];
if (!staging) {
console.error("usage: node smoke-loader.mjs <staging-dir>");
process.exit(1);
}
// ---- vscode API stub (only what the loader touches) -------------------------
const executedCommands = [];
const statusBarItems = [];
function makeVscodeStub(
sandbox,
settings = {},
{ telemetryEnabled = false } = {},
) {
return {
Uri: {
file: (fsPath) => ({ fsPath, path: fsPath, scheme: "file" }),
joinPath: (base, ...segments) => {
const fsPath = path.join(base.fsPath, ...segments);
return { fsPath, path: fsPath, scheme: "file" };
},
},
commands: {
executeCommand: async (command, ...args) => {
executedCommands.push([command, ...args]);
},
},
workspace: {
getConfiguration: (section) => ({
get: (key) => settings[`${section}.${key}`],
}),
},
window: {
createStatusBarItem: () => {
const item = {
text: "",
tooltip: "",
shown: false,
show() {
this.shown = true;
},
dispose() {},
};
statusBarItems.push(item);
return item;
},
},
StatusBarAlignment: { Left: 1, Right: 2 },
env: { machineId: "smoke-machine", isTelemetryEnabled: telemetryEnabled },
version: "0.0.0-smoke",
_sandbox: sandbox,
};
}
function makeContext(sandbox, globalStateSeed = {}, packageJSON = {}) {
const state = new Map(Object.entries(globalStateSeed));
return {
extensionUri: { fsPath: sandbox, path: sandbox, scheme: "file" },
extensionPath: sandbox,
extension: { packageJSON: { version: "4.1.0-smoke", ...packageJSON } },
subscriptions: [],
globalState: {
get: (key) => state.get(key),
update: async (key, value) => void state.set(key, value),
_dump: () => Object.fromEntries(state),
},
asAbsolutePath: (rel) => path.join(sandbox, rel),
};
}
/** PostHog /capture/ POSTs recorded by a scenario's fetch stub, parsed. */
function captureCalls(fetchCalls) {
return fetchCalls
.filter(([url]) => String(url).includes("/capture/"))
.map(([, init]) => JSON.parse(init.body));
}
function captureEvents(fetchCalls, event) {
return captureCalls(fetchCalls).filter((capture) => capture.event === event);
}
function loaderDecisionCaptures(fetchCalls) {
return captureEvents(fetchCalls, "extension.rollout.loader_decision");
}
function featureFlagCalledCaptures(fetchCalls) {
return captureEvents(fetchCalls, "$feature_flag_called");
}
function decideCalls(fetchCalls) {
return fetchCalls.filter(([url]) => String(url).includes("/decide"));
}
function flagResponse(flags = { rollout: false }) {
return {
ok: true,
json: async () => ({
featureFlags: {
"ext-sdk-bundle-rollout": flags.rollout,
},
}),
};
}
function makeFlagFetch(flags) {
const calls = [];
const fetch = async (...args) => {
calls.push(args);
return flagResponse(flags);
};
return { calls, fetch };
}
function makeDeferredFlagFetch(flags) {
const calls = [];
let resolveResponse;
let markStarted;
const started = new Promise((resolve) => {
markStarted = resolve;
});
const fetch = (...args) => {
calls.push(args);
markStarted();
return new Promise((resolve) => {
resolveResponse = () => resolve(flagResponse(flags));
});
};
return {
calls,
fetch,
started,
resolve: () => resolveResponse?.(),
};
}
async function waitFor(predicate, message, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) {
assert.fail(message);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
async function flushAsyncWork() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
// ---- sandbox setup -----------------------------------------------------------
function makeSandbox({
nextThrows = false,
legacyThrows = false,
omitReportExport = false,
} = {}) {
const sandbox = mkdtempSync(path.join(tmpdir(), "cline-ab-smoke-"));
cpSync(
path.join(staging, "extension.js"),
path.join(sandbox, "extension.js"),
);
for (const bundle of ["next", "legacy"]) {
mkdirSync(path.join(sandbox, bundle, "dist"), { recursive: true });
const throws =
(bundle === "next" && nextThrows) ||
(bundle === "legacy" && legacyThrows);
const throwLine = throws
? `await global.__smoke.beforeNextFailure?.();\n\t\tctx.subscriptions.push({ dispose() { global.__smoke.disposed.push("${bundle}") } });\n\t\tthrow new Error("smoke: ${bundle} activation exploded");`
: "";
// Mirrors the reportRolloutActivation export both real bundles gained in
// their rollout-telemetry PRs; recorded so scenarios can assert the
// authoritative attempted/actual/fallback record.
const reportExport = omitReportExport
? ""
: `exports.reportRolloutActivation = async (input) => { global.__smoke.reports.push({ reporter: "${bundle}", attemptedBundle: input.attemptedBundle, actualBundle: input.actualBundle, fallback: input.fallback, hasError: input.error !== undefined }); };`;
writeFileSync(
path.join(sandbox, bundle, "dist", "extension.js"),
`exports.activate = async (ctx) => {
${throwLine}
global.__smoke.activated.push({ bundle: "${bundle}", extensionPath: ctx.extensionPath, asAbs: ctx.asAbsolutePath("webview-ui/build") });
return { bundle: "${bundle}" };
};
exports.deactivate = () => { global.__smoke.deactivated.push("${bundle}"); };
${reportExport}`,
);
}
mkdirSync(path.join(sandbox, "data"), { recursive: true });
writeFileSync(
path.join(sandbox, "data", "globalState.json"),
JSON.stringify({ "cline.generatedMachineId": "smoke-machine" }),
);
return sandbox;
}
async function runScenario(
name,
{
seed = {},
env = {},
settings = {},
nextThrows = false,
legacyThrows = false,
omitReportExport = false,
telemetryEnabled = false,
contextPackageJSON = {},
expectFailure = false,
fetchController = makeFlagFetch(),
beforeNextFailure,
expectRefresh = true,
},
checks,
afterDeactivateChecks = async () => {},
) {
const sandbox = makeSandbox({ nextThrows, legacyThrows, omitReportExport });
global.__smoke = {
activated: [],
deactivated: [],
disposed: [],
reports: [],
beforeNextFailure,
};
executedCommands.length = 0;
statusBarItems.length = 0;
const previousEnv = {};
const scenarioEnv = {
CLINE_DIR: sandbox,
// A dev build leaves this lookup dynamic; production builds inline the
// real PostHog key. Either way, the smoke must exercise refreshCohort.
TELEMETRY_SERVICE_API_KEY: "smoke-posthog-project-key",
...env,
};
for (const [key, value] of Object.entries(scenarioEnv)) {
previousEnv[key] = process.env[key];
process.env[key] = value;
}
const originalFetch = global.fetch;
global.fetch = fetchController.fetch;
const originalResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...rest) {
if (request === "vscode") {
return "vscode";
}
return originalResolve.call(this, request, ...rest);
};
require.cache.vscode = {
id: "vscode",
filename: "vscode",
loaded: true,
exports: makeVscodeStub(sandbox, settings, { telemetryEnabled }),
};
try {
const loaderPath = path.join(sandbox, "extension.js");
delete require.cache[loaderPath];
const loader = require(loaderPath);
const context = makeContext(sandbox, seed, contextPackageJSON);
let api;
let activationError;
try {
api = await loader.activate(context);
} catch (error) {
activationError = error;
}
if (expectFailure) {
assert.ok(activationError, `${name} should have failed to activate`);
} else if (activationError) {
throw activationError;
}
if (expectRefresh) {
await waitFor(
() => decideCalls(fetchController.calls).length > 0,
`${name} did not refresh its cohort after activation`,
);
await flushAsyncWork();
assert.equal(
decideCalls(fetchController.calls).length,
1,
`${name} should refresh its cohort exactly once`,
);
}
await checks({
context,
api,
activationError,
sandbox,
fetchCalls: fetchController.calls,
});
await loader.deactivate();
await afterDeactivateChecks({ context, api, sandbox });
console.log(`PASS ${name}`);
} finally {
Module._resolveFilename = originalResolve;
delete require.cache.vscode;
if (originalFetch === undefined) {
delete global.fetch;
} else {
global.fetch = originalFetch;
}
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
rmSync(sandbox, { recursive: true, force: true });
}
}
const require = Module.createRequire(import.meta.url);
await runScenario("default cohort -> legacy", {}, async ({ api, sandbox }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.equal(
global.__smoke.activated[0].extensionPath,
path.join(sandbox, "legacy"),
);
assert.deepEqual(executedCommands[0], [
"setContext",
"cline.sdkBundle",
false,
]);
assert.deepEqual(global.__smoke.deactivated, []);
// The activated bundle received the authoritative activation record.
assert.deepEqual(global.__smoke.reports, [
{
reporter: "legacy",
attemptedBundle: "legacy",
actualBundle: "legacy",
fallback: false,
hasError: false,
},
]);
// Stable identity: no nightly status bar indicator.
assert.equal(statusBarItems.length, 0);
});
await runScenario(
"cached next -> next with scoped paths",
{ seed: { "cline.rollout.bundle": "next" } },
async ({ api, sandbox }) => {
assert.deepEqual(api, { bundle: "next" });
const activation = global.__smoke.activated[0];
assert.equal(activation.extensionPath, path.join(sandbox, "next"));
assert.equal(
activation.asAbs,
path.join(sandbox, "next", "webview-ui", "build"),
);
assert.deepEqual(executedCommands[0], [
"setContext",
"cline.sdkBundle",
true,
]);
assert.deepEqual(global.__smoke.reports, [
{
reporter: "next",
attemptedBundle: "next",
actualBundle: "next",
fallback: false,
hasError: false,
},
]);
},
);
await runScenario(
"rollout flag on promotes for the NEXT window only",
{
fetchController: makeFlagFetch({ rollout: true }),
},
async ({ context, api, fetchCalls }) => {
// This window already decided legacy from the (empty) cache; the refresh
// promotes the NEXT window.
assert.deepEqual(api, { bundle: "legacy" });
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "next");
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
assert.equal(featureFlagCalled.properties.$feature_flag_response, true);
},
);
await runScenario(
"rollout flag off demotes a cached next for the NEXT window (two-way)",
{
seed: { "cline.rollout.bundle": "next" },
fetchController: makeFlagFetch({ rollout: false }),
},
async ({ context, api, fetchCalls }) => {
// This window already ran next; dialing the flag down moves the machine
// back to legacy on its next reload.
assert.deepEqual(api, { bundle: "next" });
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
assert.equal(featureFlagCalled.event, "$feature_flag_called");
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
assert.equal(featureFlagCalled.properties.$feature_flag_response, false);
},
);
await runScenario(
"env override forces next",
{ env: { CLINE_BUNDLE_OVERRIDE: "next" } },
async ({ api }) => {
assert.deepEqual(api, { bundle: "next" });
},
);
await runScenario(
"user setting overrides to legacy despite cached next",
{
seed: { "cline.rollout.bundle": "next" },
settings: { "cline.rollout.bundleOverride": "legacy" },
},
async ({ api }) => {
assert.deepEqual(api, { bundle: "legacy" });
},
);
await runScenario(
"user setting overrides to next despite a cached legacy assignment",
{
seed: { "cline.rollout.bundle": "legacy" },
settings: { "cline.rollout.bundleOverride": "next" },
},
async ({ api }) => {
assert.deepEqual(api, { bundle: "next" });
},
);
const failedNextRefresh = makeDeferredFlagFetch({ rollout: true });
await runScenario(
"next activation failure falls back to legacy",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
fetchController: failedNextRefresh,
expectRefresh: false,
beforeNextFailure: () =>
Promise.race([
failedNextRefresh.started,
new Promise((resolve) => setTimeout(resolve, 100)),
]),
},
async ({ context, api, fetchCalls }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.deepEqual(
global.__smoke.disposed,
["next"],
"partial registrations disposed",
);
const state = context.globalState._dump();
assert.equal(state["cline.rollout.bundle"], "legacy");
assert.equal(
state["cline.rollout.nextActivationFailedVersion"],
"4.1.0-smoke",
);
assert.equal(
context.subscriptions.length,
0,
"failed bundle's subscriptions removed",
);
// setContext flipped back for the legacy UI
assert.deepEqual(executedCommands.at(-1), [
"setContext",
"cline.sdkBundle",
false,
]);
// The LEGACY bundle (the one whose telemetry pipeline is alive) received
// the authoritative fallback record; the dead next bundle reported nothing.
assert.deepEqual(global.__smoke.reports, [
{
reporter: "legacy",
attemptedBundle: "next",
actualBundle: "legacy",
fallback: true,
hasError: true,
},
]);
// Keep the fetch stub installed long enough for an incorrectly delayed
// refresh to reach the network boundary before asserting its absence.
await new Promise((resolve) => setTimeout(resolve, 100));
// Settle a refresh if the loader incorrectly launched one. With the old
// ordering it would now promote COHORT_STATE_KEY back to next.
if (decideCalls(fetchCalls).length > 0) {
failedNextRefresh.resolve();
await flushAsyncWork();
}
assert.equal(
decideCalls(fetchCalls).length,
0,
"crash fallback must not refresh the failed cohort",
);
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
},
);
await runScenario(
"loader_decision capture carries the loader-side metadata",
{
env: { CLINE_BUNDLE_OVERRIDE: "next" },
telemetryEnabled: true,
contextPackageJSON: { name: "claude-dev" },
},
async ({ fetchCalls }) => {
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length > 0,
"loader_decision capture never reached the network",
);
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(captures.length, 1);
const [capture] = captures;
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.bundle, "next");
assert.equal(capture.properties.attempted_bundle, "next");
assert.equal(capture.properties.fallback, false);
assert.equal(capture.properties.override, "env");
assert.equal(capture.properties.loader_version, "4.1.0-smoke");
assert.equal(capture.properties.extension_name, "claude-dev");
},
);
await runScenario(
"crash fallback captures exactly one loader_decision event",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
telemetryEnabled: true,
expectRefresh: false,
},
async ({ fetchCalls }) => {
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length > 0,
"fallback loader_decision capture never reached the network",
);
// Give an incorrect second capture (the pre-fix fallback:false event from
// the recursive legacy success) time to reach the network before counting.
await new Promise((resolve) => setTimeout(resolve, 100));
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(
captures.length,
1,
"fallback must emit exactly ONE loader event (regression: duplicate fallback:false event)",
);
const [capture] = captures;
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.bundle, "legacy");
assert.equal(capture.properties.attempted_bundle, "next");
assert.equal(capture.properties.fallback, true);
assert.match(capture.properties.error_message, /next activation exploded/);
},
);
await runScenario(
"a bundle without the reportRolloutActivation export still activates",
{ omitReportExport: true },
async ({ api }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.deepEqual(global.__smoke.reports, []);
},
);
await runScenario(
"nightly identity: namespaced setting + context key, status bar indicator",
{
contextPackageJSON: { name: "cline-nightly" },
settings: { "cline-nightly.rollout.bundleOverride": "next" },
},
async ({ api, context }) => {
assert.deepEqual(api, { bundle: "next" });
assert.deepEqual(executedCommands[0], [
"setContext",
"cline-nightly.sdkBundle",
true,
]);
assert.equal(statusBarItems.length, 1);
const [item] = statusBarItems;
assert.equal(item.shown, true);
assert.equal(item.text, "Cline: Next");
assert.match(item.tooltip, /bundleOverride setting/);
assert.ok(
context.subscriptions.includes(item),
"indicator must be disposed with the extension",
);
},
);
await runScenario(
"double failure: both bundles throw, loader reports and rethrows",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
legacyThrows: true,
telemetryEnabled: true,
expectFailure: true,
expectRefresh: false,
},
async ({ activationError, fetchCalls }) => {
assert.match(String(activationError), /legacy activation exploded/);
assert.deepEqual(
global.__smoke.reports,
[],
"no bundle survived to report the authoritative event",
);
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length >= 2,
"double failure should capture the fallback AND the double_failure events",
);
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(captures.length, 2);
for (const capture of captures) {
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.fallback, true);
}
const doubleFailure = captures.find(
(c) => c.properties.double_failure === true,
);
assert.ok(doubleFailure, "one capture must be flagged double_failure");
assert.equal(doubleFailure.properties.attempted_bundle, "next");
assert.match(
doubleFailure.properties.error_message,
/legacy activation exploded/,
);
},
);
await runScenario(
"deactivate delegates to active bundle",
{},
async () => {},
async () => {
assert.deepEqual(global.__smoke.deactivated, ["legacy"]);
},
);
console.log("\nall loader smoke scenarios passed");
+180
View File
@@ -0,0 +1,180 @@
/**
* Assemble the combined (loader + next + legacy) VSIX staging directory.
*
* Layout produced:
* <out>/
* extension.js loader bundle (this package's dist/extension.js)
* package.json union manifest (gen-manifest.mjs)
* README.md next's marketplace README
* LICENSE, CHANGELOG.md, assets/, walkthrough/ from next (manifest-referenced, VSIX-root-relative)
* next/ SDK extension payload (dist/, webview-ui/build/, assets/, package.json)
* legacy/ legacy extension payload (dist/, webview-ui/build/, assets/,
* node_modules/@vscode/codicons/dist/, package.json)
*
* Each bundle resolves its own resources under its subdirectory because the
* loader hands it an ExtensionContext whose extensionUri/extensionPath point
* there (see src/scoped-context.ts). Manifest-referenced resources (icons,
* walkthrough media, codicon font declared in contributes.icons) resolve from
* the VSIX root, where the stitcher places next's copies.
*
* Usage:
* node stitch.mjs --next <apps/vscode dir, built> --legacy <apps/vscode dir, built> \
* --loader <dist/extension.js> --version <x.y.z> --out <staging dir>
*/
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { generateManifest } from "./gen-manifest.mjs";
// Legacy's webview loads codicon.css straight from node_modules (see its
// WebviewProvider); next bundles the font into its webview build but its own
// .vscodeignore still re-includes the codicons dist, so mirror that here.
const BUNDLE_PAYLOAD = {
next: [
"dist",
"webview-ui/build",
"assets",
"package.json",
"node_modules/@vscode/codicons/dist",
],
legacy: [
"dist",
"webview-ui/build",
"assets",
"package.json",
"node_modules/@vscode/codicons/dist",
],
};
/** VSIX-root files, all taken from the next checkout (manifest fields come from next too). */
const ROOT_PAYLOAD = ["LICENSE", "CHANGELOG.md", "assets", "walkthrough"];
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
function copyInto(sourceRoot, relPaths, destRoot, { optional = [] } = {}) {
for (const rel of relPaths) {
const source = path.join(sourceRoot, rel);
if (!existsSync(source)) {
if (optional.includes(rel)) {
console.warn(` skip (missing, optional): ${rel}`);
continue;
}
throw new Error(
`required payload missing: ${source} — did the bundle build run?`,
);
}
cpSync(source, path.join(destRoot, rel), {
recursive: true,
dereference: true,
});
console.log(` + ${rel}`);
}
}
export function stitch({ next, legacy, loader, version, out }) {
for (const [name, value] of Object.entries({
next,
legacy,
loader,
version,
out,
})) {
if (!value) {
throw new Error(`--${name} is required`);
}
}
// Refuse to stage from an unbuilt tree early, with a clear message.
for (const [name, root] of [
["next", next],
["legacy", legacy],
]) {
if (!existsSync(path.join(root, "dist", "extension.js"))) {
throw new Error(
`${name} bundle not built: ${root}/dist/extension.js missing`,
);
}
if (
!existsSync(path.join(root, "webview-ui", "build", "assets", "index.js"))
) {
throw new Error(
`${name} webview not built: ${root}/webview-ui/build/assets/index.js missing`,
);
}
}
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
console.log("root payload (from next):");
copyInto(next, ROOT_PAYLOAD, out, {
optional: ["CHANGELOG.md", "walkthrough"],
});
cpSync(loader, path.join(out, "extension.js"));
console.log(" + extension.js (loader)");
const readme = path.join(next, "README.marketplace.md");
cpSync(
existsSync(readme) ? readme : path.join(next, "README.md"),
path.join(out, "README.md"),
);
console.log(" + README.md");
for (const [bundle, payload] of Object.entries(BUNDLE_PAYLOAD)) {
const sourceRoot = bundle === "next" ? next : legacy;
console.log(`${bundle} payload:`);
copyInto(sourceRoot, payload, path.join(out, bundle), {
optional: ["walkthrough"],
});
}
const manifest = generateManifest(
JSON.parse(readFileSync(path.join(next, "package.json"), "utf8")),
JSON.parse(readFileSync(path.join(legacy, "package.json"), "utf8")),
version,
);
writeFileSync(
path.join(out, "package.json"),
`${JSON.stringify(manifest, null, "\t")}\n`,
);
console.log(" + package.json (union manifest)");
// vsce packages everything in the staging dir; only strip sourcemaps and
// junk. The codicons files under legacy/node_modules must survive, so no
// blanket node_modules ignore here — staging only ever contains what this
// script copied.
writeFileSync(
path.join(out, ".vscodeignore"),
["**/*.map", "**/.DS_Store", ""].join("\n"),
);
console.log(`\nstaged ${out} (version ${version})`);
// Keep the scanner exemption category-scoped to match the standalone bundle
// workflows; see the README for its scope and verification notes.
console.log(
`package it with:\n cd ${out} && vsce package --no-dependencies --allow-package-secrets sendgrid`,
);
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
try {
stitch(parseArgs(process.argv));
} catch (error) {
console.error(`stitch failed: ${error.message}`);
process.exit(1);
}
}
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import {
bundleContextKey,
decideBundle,
decisionOverrideSource,
idPrefix,
parseRolloutAssignment,
ROLLOUT_FLAG,
settingSection,
} from "./cohort";
const base = {
envOverride: undefined,
settingOverride: undefined,
cached: undefined,
previousFailure: false,
};
describe("decideBundle", () => {
it("defaults to legacy with no cached assignment", () => {
expect(decideBundle(base)).toBe("legacy");
});
it("uses the cached assignment", () => {
expect(decideBundle({ ...base, cached: "next" })).toBe("next");
expect(decideBundle({ ...base, cached: "legacy" })).toBe("legacy");
});
it("treats unknown cached values as legacy", () => {
expect(decideBundle({ ...base, cached: "garbage" })).toBe("legacy");
});
it("a previous activation failure on this version forces legacy", () => {
expect(
decideBundle({ ...base, cached: "next", previousFailure: true }),
).toBe("legacy");
});
it("env override beats everything, including a previous failure", () => {
expect(
decideBundle({ ...base, envOverride: "next", previousFailure: true }),
).toBe("next");
expect(
decideBundle({ ...base, envOverride: "legacy", cached: "next" }),
).toBe("legacy");
});
it("user setting overrides in both directions", () => {
expect(
decideBundle({ ...base, settingOverride: "next", previousFailure: true }),
).toBe("next");
expect(
decideBundle({ ...base, settingOverride: "legacy", cached: "next" }),
).toBe("legacy");
});
it("env override beats the user setting", () => {
expect(
decideBundle({ ...base, envOverride: "legacy", settingOverride: "next" }),
).toBe("legacy");
});
it("ignores invalid and 'auto' overrides", () => {
expect(decideBundle({ ...base, envOverride: "beta", cached: "next" })).toBe(
"next",
);
expect(
decideBundle({ ...base, settingOverride: "auto", cached: "next" }),
).toBe("next");
});
});
describe("decisionOverrideSource", () => {
it("reports which override was active", () => {
expect(decisionOverrideSource(base)).toBeUndefined();
expect(decisionOverrideSource({ ...base, settingOverride: "next" })).toBe(
"setting",
);
expect(
decisionOverrideSource({
...base,
envOverride: "legacy",
settingOverride: "next",
}),
).toBe("env");
expect(
decisionOverrideSource({ ...base, settingOverride: "auto" }),
).toBeUndefined();
});
});
describe("parseRolloutAssignment", () => {
it("promotes only on a literal boolean true", () => {
expect(
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: true } }),
).toBe("next");
});
it("is two-way: anything else resolves to legacy (fail-safe)", () => {
// false = dialed out of the cohort; the rest = mis-configured flag.
for (const value of ["test", "control", 1, 0.5, {}, false, undefined]) {
expect(
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: value } }),
).toBe("legacy");
}
// Flag deleted / not created yet: nobody promoted.
expect(parseRolloutAssignment({ featureFlags: {} })).toBe("legacy");
});
it("returns undefined for malformed responses (cache left untouched)", () => {
expect(parseRolloutAssignment(undefined)).toBeUndefined();
expect(parseRolloutAssignment({})).toBeUndefined();
expect(parseRolloutAssignment({ featureFlags: null })).toBeUndefined();
});
});
describe("identity prefix", () => {
it("maps the nightly manifest name to the cline-nightly namespace", () => {
expect(idPrefix("cline-nightly")).toBe("cline-nightly");
});
it("maps everything else (stable claude-dev, unknown, missing) to cline", () => {
expect(idPrefix("claude-dev")).toBe("cline");
expect(idPrefix("some-fork")).toBe("cline");
expect(idPrefix(undefined)).toBe("cline");
});
it("derives the setting section and context key from the prefix", () => {
expect(settingSection("cline")).toBe("cline.rollout");
expect(settingSection("cline-nightly")).toBe("cline-nightly.rollout");
expect(bundleContextKey("cline")).toBe("cline.sdkBundle");
expect(bundleContextKey("cline-nightly")).toBe("cline-nightly.sdkBundle");
});
});
+134
View File
@@ -0,0 +1,134 @@
export type Bundle = "next" | "legacy";
/**
* The combined VSIX ships under two identities: the stable extension
* (manifest name "claude-dev", contribution IDs under "cline.*") and the
* nightly (name "cline-nightly", IDs under "cline-nightly.*" the nightly
* packaging rewrites every `"cline.` prefix in the manifest, see
* scripts/nightlify.mjs and apps/vscode/scripts/publish-nightly.mjs). Anything
* the loader reads from or feeds back into the manifest namespace the
* bundleOverride setting and the sdkBundle context key must use the prefix
* matching the installed identity. scripts/gen-manifest.mjs derives the same
* prefix when generating the union manifest; keep them in sync.
*/
export const NIGHTLY_EXTENSION_NAME = "cline-nightly";
export type IdPrefix = "cline" | "cline-nightly";
export function idPrefix(extensionName: string | undefined): IdPrefix {
return extensionName === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline";
}
/** Settings section holding the bundleOverride escape hatch. */
export function settingSection(prefix: IdPrefix): string {
return `${prefix}.rollout`;
}
/** Context key gating per-cohort menus/keybindings in the union manifest. */
export function bundleContextKey(prefix: IdPrefix): string {
return `${prefix}.sdkBundle`;
}
/**
* Loader-owned VS Code memento keys. Never touched by either bundle. These
* deliberately stay un-prefixed by identity: globalState is already scoped to
* the extension ID, so a stable and a nightly install can never collide.
*/
export const COHORT_STATE_KEY = "cline.rollout.bundle";
/** Version of the combined VSIX whose `next` bundle failed to activate, if any. */
export const FAILED_VERSION_STATE_KEY =
"cline.rollout.nextActivationFailedVersion";
/** Epoch ms of the previous loader activation, for launch-cadence telemetry. */
export const LAST_ACTIVATION_STATE_KEY = "cline.rollout.lastActivationAt";
/**
* PostHog rollout flag (created in the Cline PostHog project). Must be a
* plain BOOLEAN release flag with a percentage rollout.
*
* The assignment is TWO-WAY: each background refresh caches exactly what the
* flag says (true => next, anything else => legacy) for the next window, so
* dialing the percentage down moves machines back to legacy on their next
* reload the single emergency lever is "set the rollout to 0%". Demoted
* machines keep their settings/creds (the state files round-trip), but tasks
* created on the SDK bundle aren't visible in legacy's history until
* re-promoted, and tokens rotated on next may require re-auth on legacy.
*/
export const ROLLOUT_FLAG = "ext-sdk-bundle-rollout";
/** Env var for local dev / e2e to force a bundle. Beats everything. */
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE";
/**
* User-visible escape hatch: `<prefix>.rollout.bundleOverride` in VS Code
* settings ("auto" | "next" | "legacy") see settingSection() for the
* identity-dependent section name. Editable from settings.json without
* touching mementos, beats the remote flag in either direction, applies on
* window reload. Injected into the union manifest by gen-manifest.mjs keep
* the schema there in sync with these constants.
*/
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride";
function asBundle(value: unknown): Bundle | undefined {
return value === "next" || value === "legacy" ? value : undefined;
}
export interface CohortInputs {
/** CLINE_BUNDLE_OVERRIDE, if set. */
envOverride: string | undefined;
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
settingOverride: string | undefined;
/** Cached assignment from the previous window's background flag refresh. */
cached: string | undefined;
/** The next bundle failed to activate on this VSIX version before. */
previousFailure: boolean;
}
/**
* Decide which bundle to activate for this window. Must be synchronous and
* never block on the network: it only consumes state cached by the previous
* window's background refresh, so a percentage change applies on the next
* window reload, mirroring how VS Code's own experiments behave.
*/
export function decideBundle(inputs: CohortInputs): Bundle {
const forced =
asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride);
if (forced) {
return forced;
}
if (inputs.previousFailure) {
return "legacy";
}
return inputs.cached === "next" ? "next" : "legacy";
}
/** Which override produced the decision, if any — reported on the activation event. */
export function decisionOverrideSource(
inputs: Pick<CohortInputs, "envOverride" | "settingOverride">,
): "env" | "setting" | undefined {
if (asBundle(inputs.envOverride)) {
return "env";
}
if (asBundle(inputs.settingOverride)) {
return "setting";
}
return undefined;
}
/**
* Parse a PostHog /decide (v3) response into the assignment to cache for the
* next window, or undefined when the response is malformed (leave the cached
* assignment untouched sticky on transient failures).
*
* Deliberately strict so a mis-configured flag fails SAFE toward legacy: only
* boolean `true` promotes. A multivariate variant string, a number, a payload,
* or a missing/deleted flag all resolve to legacy the flag must stay a plain
* boolean release flag with a percentage rollout.
*/
export function parseRolloutAssignment(response: unknown): Bundle | undefined {
const flags = (
response as { featureFlags?: Record<string, unknown> } | undefined
)?.featureFlags;
if (!flags || typeof flags !== "object") {
return undefined;
}
return flags[ROLLOUT_FLAG] === true ? "next" : "legacy";
}
+259
View File
@@ -0,0 +1,259 @@
import { createRequire } from "node:module";
import path from "node:path";
import * as vscode from "vscode";
import {
BUNDLE_OVERRIDE_ENV,
type Bundle,
bundleContextKey,
COHORT_STATE_KEY,
decideBundle,
decisionOverrideSource,
FAILED_VERSION_STATE_KEY,
type IdPrefix,
idPrefix,
LAST_ACTIVATION_STATE_KEY,
SETTING_BUNDLE_OVERRIDE,
settingSection,
} from "./cohort";
import { refreshCohort, reportLoaderDecision } from "./rollout";
import { scopedContext } from "./scoped-context";
/**
* Cline rollout loader.
*
* The VSIX ships two complete, independently built extension bundles:
* next/ the SDK-based extension (built from main's apps/vscode)
* legacy/ the pre-SDK extension (built from the legacy-extension branch)
*
* This entrypoint picks exactly one per window from state cached by the
* previous window's background flag refresh, never from a blocking network
* call activates it with a context whose install-root paths point into its
* subdirectory, and delegates everything else to it. If the next bundle throws
* during activation, the loader disposes whatever it half-registered, pins
* this VSIX version back to legacy, and activates legacy instead.
*/
// Resolved at runtime relative to the installed VSIX root; must stay opaque to
// esbuild so the bundles aren't inlined into the loader.
const requireFromVsixRoot = createRequire(__filename);
interface BundleModule {
activate(context: vscode.ExtensionContext): Promise<unknown> | unknown;
deactivate?(): Promise<void> | void;
/**
* Exported by both bundles' entrypoints (see rollout-metadata.ts on each
* branch): captures the AUTHORITATIVE `extension.rollout.bundle_activated`
* event through the bundle's own variant-attributed telemetry pipeline.
* Optional so the loader keeps working against a bundle built before the
* export existed.
*/
reportRolloutActivation?(input: {
attemptedBundle: Bundle;
actualBundle: Bundle;
fallback: boolean;
error?: unknown;
}): Promise<void>;
}
let activeBundle: { module: BundleModule; name: Bundle } | undefined;
interface ActivationMeta {
msSinceLastActivation?: number;
override?: "env" | "setting";
}
/** Set when the original decision crashed and this activation is the fallback. */
interface FallbackFrom {
attempted: Bundle;
error: unknown;
}
export async function activate(context: vscode.ExtensionContext) {
const loaderVersion: string =
context.extension.packageJSON?.version ?? "unknown";
const prefix = idPrefix(context.extension.packageJSON?.name);
// Launch-cadence telemetry: how stale the previous activation is bounds how
// fast a percentage change can actually reach users' windows.
const lastActivationAt = context.globalState.get<number>(
LAST_ACTIVATION_STATE_KEY,
);
const now = Date.now();
void context.globalState.update(LAST_ACTIVATION_STATE_KEY, now);
const overrides = {
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
settingOverride: vscode.workspace
.getConfiguration(settingSection(prefix))
.get<string>(SETTING_BUNDLE_OVERRIDE),
};
const bundle = decideBundle({
...overrides,
cached: context.globalState.get<string>(COHORT_STATE_KEY),
previousFailure:
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) ===
loaderVersion,
});
const meta: ActivationMeta = {
msSinceLastActivation:
typeof lastActivationAt === "number" && lastActivationAt <= now
? now - lastActivationAt
: undefined,
override: decisionOverrideSource(overrides),
};
return activateBundle(context, prefix, bundle, loaderVersion, meta, true);
}
async function activateBundle(
context: vscode.ExtensionContext,
prefix: IdPrefix,
bundle: Bundle,
loaderVersion: string,
meta: ActivationMeta,
refreshAssignmentOnSuccess: boolean,
fallbackFrom?: FallbackFrom,
): Promise<unknown> {
// Menus/keybindings gated per cohort in package.json key off this.
await vscode.commands.executeCommand(
"setContext",
bundleContextKey(prefix),
bundle === "next",
);
const subscriptionsBefore = context.subscriptions.length;
try {
const module = requireFromVsixRoot(
path.join(__dirname, bundle, "dist", "extension.js"),
) as BundleModule;
const api = await module.activate(scopedContext(context, bundle));
activeBundle = { module, name: bundle };
// Cache the next window's assignment only after the originally selected
// bundle activates. A crash fallback must not start a refresh that could
// promote the cohort back to next after the handler pins it to legacy.
if (refreshAssignmentOnSuccess) {
void refreshCohort(context).catch(() => {});
}
// Authoritative activation event, captured by the bundle's own telemetry
// (built with CLINE_ROLLOUT_VARIANT). On fallback this runs in the legacy
// bundle — next's pipeline is the thing that just crashed.
if (typeof module.reportRolloutActivation === "function") {
void module
.reportRolloutActivation({
attemptedBundle: fallbackFrom?.attempted ?? bundle,
actualBundle: bundle,
fallback: fallbackFrom !== undefined,
error: fallbackFrom?.error,
})
.catch(() => {});
}
// The loader's own decision event fires once per window: the fallback
// path already reported (fallback: true) from the catch block below.
if (!fallbackFrom) {
void reportLoaderDecision(context, bundle, {
...meta,
fallback: false,
}).catch(() => {});
}
showNightlyBundleIndicator(context, prefix, bundle, meta, fallbackFrom);
return api;
} catch (error) {
if (bundle === "legacy") {
// Nothing left to fall back to; let VS Code surface the failure. When
// this was already the crash fallback, no bundle telemetry pipeline is
// alive — the loader's direct event is the only record.
void reportLoaderDecision(context, "legacy", {
...meta,
attemptedBundle: fallbackFrom?.attempted ?? "legacy",
fallback: fallbackFrom !== undefined,
doubleFailure: fallbackFrom !== undefined,
errorMessage: formatActivationError(error),
}).catch(() => {});
throw error;
}
console.error(
"[cline-rollout] next bundle failed to activate, falling back to legacy:",
error,
);
disposeSubscriptionsAddedAfter(context, subscriptionsBefore);
// Pin this VSIX version to legacy so we don't crash-loop every window.
// A new release (new version string) gets to try next again.
await context.globalState.update(FAILED_VERSION_STATE_KEY, loaderVersion);
await context.globalState.update(COHORT_STATE_KEY, "legacy");
void reportLoaderDecision(context, "legacy", {
...meta,
attemptedBundle: "next",
fallback: true,
errorMessage: formatActivationError(error),
}).catch(() => {});
return activateBundle(
context,
prefix,
"legacy",
loaderVersion,
meta,
false,
{ attempted: "next", error },
);
}
}
function formatActivationError(error: unknown): string {
return error instanceof Error
? `${error.message}\n${error.stack ?? ""}`.slice(0, 2000)
: String(error);
}
/**
* Nightly-only visible indicator of which bundle this window is running.
* The stable combined VSIX (and any ordinary build) never shows it: the
* prefix is derived from the packaged manifest name. Best-effort the
* indicator must never take down an otherwise successful activation.
*/
function showNightlyBundleIndicator(
context: vscode.ExtensionContext,
prefix: IdPrefix,
bundle: Bundle,
meta: ActivationMeta,
fallbackFrom: FallbackFrom | undefined,
) {
if (prefix !== "cline-nightly") {
return;
}
try {
const item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
-1000,
);
item.text = bundle === "next" ? "Cline: Next" : "Cline: Legacy";
const detail = fallbackFrom
? "crash fallback from the next bundle"
: meta.override
? `forced by ${meta.override === "env" ? `the ${BUNDLE_OVERRIDE_ENV} env var` : "the bundleOverride setting"}`
: "rollout assignment";
item.tooltip = `Cline nightly A/B rollout: running the ${bundle === "next" ? "next (SDK)" : "legacy"} bundle (${detail}).`;
item.show();
context.subscriptions.push(item);
} catch (error) {
console.warn("[cline-rollout] could not show bundle indicator:", error);
}
}
/** Dispose anything a failed activation managed to register before it threw. */
function disposeSubscriptionsAddedAfter(
context: vscode.ExtensionContext,
startIndex: number,
) {
const added = context.subscriptions.splice(startIndex);
for (const disposable of added) {
try {
disposable.dispose();
} catch {
// best effort — a broken disposable must not block the fallback
}
}
}
export async function deactivate() {
return activeBundle?.module.deactivate?.();
}
+223
View File
@@ -0,0 +1,223 @@
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { machineId } from "node-machine-id";
import * as vscode from "vscode";
import {
type Bundle,
COHORT_STATE_KEY,
parseRolloutAssignment,
ROLLOUT_FLAG,
} from "./cohort";
/**
* Same PostHog project + reverse proxy the extension's telemetry uses.
* The API key is injected at build time by CI (see esbuild.mjs), matching how
* apps/vscode injects TELEMETRY_SERVICE_API_KEY. Local builds without the key
* skip all network calls, so the loader defaults everyone to legacy.
*/
const POSTHOG_HOST = "https://data.cline.bot";
const POSTHOG_API_KEY = process.env.TELEMETRY_SERVICE_API_KEY;
const FETCH_TIMEOUT_MS = 10_000;
const FEATURE_FLAG_CALLED_EVENT = "$feature_flag_called";
/**
* Mirror the distinct-id derivation in apps/vscode
* (src/services/logging/distinctId.ts) so PostHog evaluates the rollout flag
* against the same id the bundles report telemetry with otherwise cohort
* membership can't be correlated with cohort behavior in dashboards.
* Falls back to vscode.env.machineId rather than generating + persisting a new
* id: the loader must never write to the shared ~/.cline state files.
*/
async function getDistinctId(): Promise<string> {
const generated = await readSharedGlobalStateKey("cline.generatedMachineId");
if (typeof generated === "string" && generated.length > 0) {
return generated;
}
try {
const id = await machineId();
if (id) {
return id;
}
} catch {
// fall through
}
return vscode.env.machineId;
}
/** Read one key from the file-backed global state both bundles share. */
async function readSharedGlobalStateKey(key: string): Promise<unknown> {
try {
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline");
const raw = await readFile(
path.join(clineDir, "data", "globalState.json"),
"utf8",
);
const state = JSON.parse(raw);
return state?.[key];
} catch {
return undefined;
}
}
async function postJson(
url: string,
body: object,
): Promise<Response | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
async function fetchAssignment(
distinctId: string,
): Promise<Bundle | undefined> {
if (!POSTHOG_API_KEY) {
return undefined;
}
const response = await postJson(`${POSTHOG_HOST}/decide?v=3`, {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
});
if (!response?.ok) {
return undefined;
}
try {
const decideResponse = await response.json();
const assignment = parseRolloutAssignment(decideResponse);
if (!assignment) {
return undefined;
}
// Mirror FeatureFlagsService/PostHog SDK exposure tracking for this
// loader-owned flag evaluation. This event is intentionally not gated by
// telemetry opt-out: feature-flag evaluation remains enabled so PostHog can
// correctly attribute rollout cohorts, while loader_decision below still
// respects user/host telemetry settings.
void reportFeatureFlagCalled(
distinctId,
getRolloutFlagResponse(decideResponse),
).catch(() => {});
return assignment;
} catch {
return undefined;
}
}
function getRolloutFlagResponse(response: unknown): unknown {
const flags = (
response as { featureFlags?: Record<string, unknown> } | undefined
)?.featureFlags;
return flags && typeof flags === "object" ? flags[ROLLOUT_FLAG] : undefined;
}
async function reportFeatureFlagCalled(
distinctId: string,
flagResponse: unknown,
): Promise<void> {
if (!POSTHOG_API_KEY) {
return;
}
await postJson(`${POSTHOG_HOST}/capture/`, {
api_key: POSTHOG_API_KEY,
event: FEATURE_FLAG_CALLED_EVENT,
distinct_id: distinctId,
properties: {
$feature_flag: ROLLOUT_FLAG,
$feature_flag_response: flagResponse,
},
});
}
/**
* Background refresh: evaluate the rollout flag and cache exactly what it
* says for the NEXT window (two-way: dialing the percentage down demotes on
* the next reload). Never affects the bundle already activated in this
* window, and failures leave the cached assignment untouched (sticky on
* transient errors only).
*/
export async function refreshCohort(
context: vscode.ExtensionContext,
): Promise<void> {
const distinctId = await getDistinctId();
const assignment = await fetchAssignment(distinctId);
if (!assignment) {
return;
}
await context.globalState.update(COHORT_STATE_KEY, assignment);
}
/**
* The loader's own decision event. Distinct from the AUTHORITATIVE
* `extension.rollout.bundle_activated` event, which the activated bundle
* itself captures through its variant-attributed telemetry pipeline (the
* loader triggers it via the bundle's reportRolloutActivation export see
* src/extension.ts). This event carries the loader-side metadata that event
* can't (override source, launch cadence, loader version) and is the only
* signal left when BOTH bundles fail to activate.
*/
export const LOADER_DECISION_EVENT = "extension.rollout.loader_decision";
/**
* Report the loader's bundle decision (and whether it was a crash fallback).
* Feature-flag evaluation is always allowed (matching the extension's
* FeatureFlagsService), but event capture respects the user's telemetry
* opt-out and VS Code's global telemetry setting.
*/
export async function reportLoaderDecision(
context: vscode.ExtensionContext,
bundle: Bundle,
options: {
fallback: boolean;
/** Bundle the loader originally decided on; differs from `bundle` on fallback. */
attemptedBundle?: Bundle;
/** Both bundles threw — nothing activated, and no bundle telemetry exists. */
doubleFailure?: boolean;
errorMessage?: string;
/** Time since the previous loader activation on this machine, if known. */
msSinceLastActivation?: number;
/** Whether an env var or user setting forced this bundle. */
override?: "env" | "setting";
},
): Promise<void> {
if (!POSTHOG_API_KEY) {
return;
}
const telemetrySetting = await readSharedGlobalStateKey("telemetrySetting");
if (telemetrySetting === "disabled" || !vscode.env.isTelemetryEnabled) {
return;
}
const distinctId = await getDistinctId();
await postJson(`${POSTHOG_HOST}/capture/`, {
api_key: POSTHOG_API_KEY,
event: LOADER_DECISION_EVENT,
distinct_id: distinctId,
properties: {
bundle,
attempted_bundle: options.attemptedBundle ?? bundle,
fallback: options.fallback,
double_failure: options.doubleFailure,
error_message: options.errorMessage,
// Launch-cadence distribution: how long promotions take to reach real
// windows tells us how fast the rollout percentage can safely be dialed.
ms_since_last_activation: options.msSinceLastActivation,
override: options.override,
loader_version: context.extension.packageJSON?.version,
// Separates nightly traffic from the (future) stable combined VSIX.
extension_name: context.extension.packageJSON?.name,
vscode_version: vscode.version,
},
});
}
+56
View File
@@ -0,0 +1,56 @@
import path from "node:path";
import * as vscode from "vscode";
import type { Bundle } from "./cohort";
/**
* Wrap the real ExtensionContext so a bundle living under `<vsix root>/<sub>/`
* resolves extension-root-relative resources (webview-ui build, walkthrough
* assets, bundled codicons, ...) from its own subtree, without either codebase
* knowing it was relocated.
*
* Only install-root properties are redirected. Storage-related properties
* (globalState, workspaceState, secrets, globalStorageUri, storageUri, logUri)
* intentionally pass through untouched: both bundles must keep sharing the
* exact storage the standalone extension used, so user state survives cohort
* changes and VSIX upgrades.
*/
export function scopedContext(
context: vscode.ExtensionContext,
sub: Bundle,
): vscode.ExtensionContext {
const extensionUri = vscode.Uri.joinPath(context.extensionUri, sub);
const extensionPath = extensionUri.fsPath;
const scopedExtension = new Proxy(context.extension, {
get(target, prop, _receiver) {
if (prop === "extensionUri") {
return extensionUri;
}
if (prop === "extensionPath") {
return extensionPath;
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
const overrides = new Map<PropertyKey, unknown>([
["extensionUri", extensionUri],
["extensionPath", extensionPath],
[
"asAbsolutePath",
(relativePath: string) => path.join(extensionPath, relativePath),
],
["extension", scopedExtension],
]);
return new Proxy(context, {
get(target, prop, _receiver) {
if (overrides.has(prop)) {
return overrides.get(prop);
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as vscode.ExtensionContext;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node", "vscode"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+46 -2
View File
@@ -63,7 +63,7 @@ service ModelsService {
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
// Writes provider configuration fields and returns redacted effective configuration
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
// Commits a mode-specific model selection atomically with its model metadata
// Commits a mode-specific model ID with optional user-authored metadata overrides
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
}
@@ -122,6 +122,36 @@ message OpenRouterModelInfo {
optional ApiFormat api_format = 16;
}
// User-authored per-model metadata stored in models.json.
//
// Semantics:
// - `capabilities` accepts only the SDK ModelCapability values (e.g.
// "images", "tools", "prompt-cache", "reasoning", "files"); unknown
// strings are silently dropped by the host. The array is additive over
// the base metadata; the explicit supports_* booleans win when both are
// present.
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
// format only when true; `api_format` is canonical.
// - Invalid numbers (non-positive token limits, negative prices or
// temperature, non-finite values) are silently discarded, not rejected.
message ModelOverrides {
optional string name = 1;
optional int64 max_tokens = 2;
optional int64 context_window = 3;
optional int64 max_input_tokens = 4;
repeated string capabilities = 5;
optional bool supports_vision = 6;
optional bool supports_attachments = 7;
optional bool supports_reasoning = 8;
optional double input_price = 9;
optional double output_price = 10;
optional double cache_reads_price = 11;
optional double cache_writes_price = 12;
optional double temperature = 13;
optional ApiFormat api_format = 14;
optional bool is_r1_format_required = 15;
}
// Shared response message for model information
message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
@@ -222,12 +252,16 @@ message ProviderConfigResponse {
optional CommittedModelSelection act_selection = 11;
optional AwsProviderConfig aws = 12;
optional GcpProviderConfig gcp = 13;
// Provider-level context window (providers.json `contextWindow`). Used by
// bring-your-own-model providers (e.g. Ollama, where it maps to num_ctx).
optional int32 context_window = 14;
}
message CommittedModelSelection {
string provider_id = 1;
string model_id = 2;
OpenRouterModelInfo model_info = 3;
optional ModelOverrides overrides = 4;
}
message ProviderReasoningPatch {
@@ -249,6 +283,8 @@ message WriteProviderConfigPatch {
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
// Provider-level context window; 0 clears the setting.
optional int32 context_window = 13;
}
message WriteProviderConfigRequest {
@@ -257,10 +293,18 @@ message WriteProviderConfigRequest {
}
message CommitModelSelectionRequest {
// Field 4 carried `OpenRouterModelInfo model_info` in earlier releases.
// Reusing the number with a different message type mis-decodes on version
// skew, so the retired field stays reserved.
reserved 4;
reserved "model_info";
string provider_id = 1;
string mode = 2;
string model_id = 3;
OpenRouterModelInfo model_info = 4;
// Tri-state: ABSENT leaves the model's stored overrides unchanged, an
// explicitly EMPTY message clears them, and a populated message replaces
// them wholesale (no per-field merge).
optional ModelOverrides overrides = 5;
}
message ClineRecommendedModel {
+3
View File
@@ -16,6 +16,9 @@ service TaskService {
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Detaches the running foreground terminal command ("Proceed While Running"):
// the agent receives the partial output and a log file path for the rest.
rpc proceedWhileRunningCommand(EmptyRequest) returns (Empty);
// Clears the current task
rpc clearTask(EmptyRequest) returns (Empty);
// Gets the total size of all tasks
@@ -56,4 +56,16 @@ describe("parseYamlFrontmatter", () => {
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
expect(result.body.trim()).to.equal("Content")
})
// Regression test for https://github.com/cline/cline/issues/12151
// A leading UTF-8 BOM (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not
// prevent frontmatter from being recognized.
it("parses frontmatter correctly when the content has a leading UTF-8 BOM", () => {
const input = `\uFEFF---\nname: my-skill\ndescription: A test skill\n---\n# my-skill\nThis is a test skill.`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ name: "my-skill", description: "A test skill" })
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
})
})
@@ -139,6 +139,34 @@ Instructions here`)
expect(skills[0].source).to.equal("global")
})
// Regression test for https://github.com/cline/cline/issues/12151:
// SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's "UTF-8 with BOM"
// encoding) were silently skipped because the frontmatter regex required "---" at the
// very start of the file and never accounted for the leading \uFEFF byte sequence.
it("should discover skills whose SKILL.md starts with a UTF-8 BOM", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`\uFEFF---
name: my-skill
description: A test skill
---
# my-skill
This is a test skill.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
expect(skills[0].description).to.equal("A test skill")
expect(skills[0].source).to.equal("global")
})
it("should discover skills from project .clinerules/skills directory", async () => {
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const skillDir = path.join(projectSkillsDir, "explaining-code")
@@ -1,3 +1,4 @@
import { stripUtf8Bom } from "@cline/shared"
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
@@ -35,11 +36,16 @@ export type FrontmatterParseResult = {
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
const normalizedMarkdown = stripUtf8Bom(markdown)
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
const match = normalizedMarkdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
@@ -48,6 +54,6 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
}
}
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import { ApiFormat, ModelOverrides } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
type TestStateManager = {
@@ -153,6 +153,22 @@ describe("provider model catalog handlers", () => {
baseUrl: "https://api.example.com/v1",
auth: { accessToken: "SECRET_SENTINEL_ACCESS", refreshToken: "SECRET_SENTINEL_REFRESH", accountId: "acct-1" },
})
vi.mocked(store.readSelection).mockImplementation((_providerId, mode) =>
mode === "act"
? {
providerId,
modelId: "custom-model",
overrides: {
capabilities: ["tools", "custom-capability"],
inputPrice: 1.25,
supportsVision: false,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
modelInfo: { name: "Custom model", contextWindow: 64_000, supportsPromptCache: false },
}
: undefined,
)
const controller = makeController(store, makeCatalog())
const response = await readProviderConfig(controller, { value: "cline" })
@@ -165,10 +181,24 @@ describe("provider model catalog handlers", () => {
hasRefreshToken: true,
accountId: "acct-1",
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(response.actSelection).toMatchObject({
providerId: "cline",
modelId: "custom-model",
modelInfo: { name: "Custom model", contextWindow: 64_000 },
overrides: {
capabilities: ["tools", "custom-capability"],
inputPrice: 1.25,
supportsVision: false,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_API_KEY")
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_ACCESS")
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_REFRESH")
})
it("writeProviderConfig writes a patch and returns redacted updated config", async () => {
it("writeProviderConfig writes a patch and returns a redacted response", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("ollama")
const updatedConfig: EffectiveProviderConfig = {
@@ -188,8 +218,10 @@ describe("provider model catalog handlers", () => {
apiKey: "SECRET_SENTINEL_OLLAMA",
baseUrl: "http://localhost:11434/v1",
})
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_OLLAMA".length)
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(response).toMatchObject({
apiKeyLength: "SECRET_SENTINEL_OLLAMA".length,
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_OLLAMA")
})
it("writeProviderConfig can explicitly clear headers", async () => {
@@ -210,7 +242,7 @@ describe("provider model catalog handlers", () => {
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
})
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
it("commitModelSelection validates mode and commits model settings", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
@@ -224,22 +256,20 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
overrides: ModelOverrides.create({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
modelInfo: expect.objectContaining({
overrides: expect.objectContaining({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
@@ -249,6 +279,49 @@ describe("provider model catalog handlers", () => {
expect(stateManager.flushPendingState).toHaveBeenCalledTimes(1)
})
// The overrides field is tri-state: absent preserves the model's stored
// overrides, an explicitly empty message clears them, and a populated
// message replaces them. The two boundary cases are pinned here because
// the webview relies on both (see useProviderConfig.test.ts).
it("commitModelSelection maps an ABSENT overrides field to undefined (preserve stored overrides)", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
overrides: undefined,
})
})
it("commitModelSelection maps an EMPTY overrides message to an empty object (clear stored overrides)", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
overrides: ModelOverrides.create({}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
overrides: {},
})
})
it("commitModelSelection reports provider changes when config is initialized", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
@@ -268,10 +341,7 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
name: "DeepSeek V4 Flash",
apiFormat: ApiFormat.OPENAI_CHAT,
}),
overrides: ModelOverrides.create({ name: "DeepSeek V4 Flash" }),
})
expect(handleApiConfigurationChanged).toHaveBeenCalledWith({}, { actModeApiProvider: "deepseek" })
@@ -289,7 +359,7 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "invalid",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({ supportsPromptCache: true }),
overrides: ModelOverrides.create({ capabilities: ["prompt-cache"] }),
}),
).rejects.toThrow('mode must be "plan" or "act"')
expect(store.commitSelection).not.toHaveBeenCalled()
@@ -75,7 +75,6 @@ describe("provider model catalog backend smoke", () => {
providerId: "deepseek",
mode: "act",
modelId,
modelInfo,
}),
)
@@ -3,12 +3,14 @@ import type {
EffectiveProviderConfig,
Mode,
ModelSelection,
ModelSelectionOverrides,
ProviderCatalog,
ProviderConfigPatch,
ProviderConfigStore,
ProviderId,
ProviderListing,
ProviderModelsResult,
ResolvedModelSelection,
} from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import {
@@ -17,13 +19,15 @@ import {
CommitModelSelectionRequest,
CommittedModelSelection,
GcpProviderConfig,
ModelOverrides as ModelOverridesProto,
OpenRouterModelInfo,
ProviderConfigResponse,
ProviderListing as ProviderListingProto,
ProviderModelsResponse,
WriteProviderConfigPatch,
} from "@/shared/proto/cline/models"
import { fromProtobufModelInfo, toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import { fromProtobufModelOverrides, toProtobufModelOverrides } from "@/shared/proto-conversions/models/modelOverrides"
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import type { GlobalStateAndSettings } from "@/shared/storage/state-keys"
export interface ProviderCatalogController {
@@ -94,7 +98,11 @@ function toProtobufModels(models: ReadonlyMap<string, ModelInfo>): Record<string
return result
}
function toCommittedModelSelectionProto(selection: ModelSelection | undefined): CommittedModelSelection | undefined {
function toModelOverridesProto(overrides: ModelSelectionOverrides | undefined): ModelOverridesProto | undefined {
return overrides ? toProtobufModelOverrides(overrides) : undefined
}
function toCommittedModelSelectionProto(selection: ResolvedModelSelection | undefined): CommittedModelSelection | undefined {
if (!selection) {
return undefined
}
@@ -102,6 +110,7 @@ function toCommittedModelSelectionProto(selection: ModelSelection | undefined):
providerId: selection.providerId,
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
overrides: toModelOverridesProto(selection.overrides),
})
}
@@ -199,6 +208,7 @@ export function toRedactedProviderConfigResponse(
actSelection: toCommittedModelSelectionProto(store?.readSelection(config.providerId, "act")),
aws: toRedactedAwsProviderConfigProto(config.aws),
gcp: toRedactedGcpProviderConfigProto(config.gcp),
contextWindow: config.contextWindow,
})
}
@@ -218,6 +228,10 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
...(protoPatch.apiLine !== undefined ? { apiLine: protoPatch.apiLine } : {}),
...(protoPatch.aws !== undefined ? { aws: toAwsProviderConfigPatch(protoPatch) } : {}),
...(protoPatch.gcp !== undefined ? { gcp: toGcpProviderConfigPatch(protoPatch) } : {}),
// A zero context window over the wire means "clear the setting".
...(protoPatch.contextWindow !== undefined
? { contextWindow: protoPatch.contextWindow > 0 ? protoPatch.contextWindow : null }
: {}),
...(protoPatch.accessToken !== undefined || protoPatch.refreshToken !== undefined || protoPatch.accountId !== undefined
? {
auth: {
@@ -241,17 +255,18 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
}
}
function toSelectionOverrides(overrides: ModelOverridesProto | undefined): ModelSelectionOverrides | undefined {
return fromProtobufModelOverrides(overrides)
}
export function toModelSelection(request: CommitModelSelectionRequest, providerId: ProviderId): ModelSelection {
const modelId = request.modelId.trim()
if (!modelId) {
throw new Error("model_id is required")
}
if (!request.modelInfo) {
throw new Error("model_info is required")
}
return {
providerId,
modelId,
modelInfo: fromProtobufModelInfo(request.modelInfo),
overrides: toSelectionOverrides(request.overrides),
}
}
@@ -12,10 +12,11 @@ import { type ProviderCatalogController, parseProviderIdRequest } from "./provid
* Resolution order:
*
* 1. Committed selection the user's most-recently-chosen plan/act
* selection in the provider config store. This is the source of
* truth for dynamic-list providers (openrouter, openai-compatible,
* ollama, lmstudio, requesty, litellm, ) where the picker writes
* the live `ModelInfo` into the selection when the user commits.
* model ID resolved against SDK catalog metadata, the picker's state
* snapshot, and stored overrides by the provider config store. A
* selection whose metadata is pure fallback fabrication (no catalog or
* state base, no overrides) is deferred behind the catalog steps below
* and only returned as a last resort.
*
* 2. Catalog peek a non-fetching look-up of the catalog cache for
* the provider's current effective config fingerprint. Hits when
@@ -41,23 +42,24 @@ export async function resolveModelInfo(
const requestedModelId = request.modelId?.trim() || ""
const store = controller.getProviderConfigStore()
// A committed selection whose metadata is pure fallback fabrication (no
// catalog/state base and no user overrides) must not shadow the live
// catalog below; it is kept only as a last resort before "unknown".
let fallbackSelection: ReturnType<typeof store.readSelection>
if (requestedModelId) {
const actSelection = store.readSelection(providerId, "act")
if (actSelection?.modelId === requestedModelId) {
for (const mode of ["act", "plan"] as const) {
const selection = store.readSelection(providerId, mode)
if (selection?.modelId !== requestedModelId) {
continue
}
if (selection.modelInfoSource === "fallback" && !selection.overrides) {
fallbackSelection ??= selection
continue
}
return ResolveModelInfoResponse.create({
providerId,
modelId: actSelection.modelId,
modelInfo: toProtobufModelInfo(actSelection.modelInfo),
source: "committed-selection",
})
}
const planSelection = store.readSelection(providerId, "plan")
if (planSelection?.modelId === requestedModelId) {
return ResolveModelInfoResponse.create({
providerId,
modelId: planSelection.modelId,
modelInfo: toProtobufModelInfo(planSelection.modelInfo),
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
source: "committed-selection",
})
}
@@ -74,7 +76,9 @@ export async function resolveModelInfo(
const cached = catalog.peekModels(providerId)
if (cached?.ok) {
const hit = pickFromCatalog(cached, requestedModelId, allowCustomModelIds)
if (hit) {
// A default-model substitution answers a question about a different
// model; the committed selection, even fallback-grade, is closer.
if (hit && (hit.matchedRequested || !fallbackSelection)) {
return ResolveModelInfoResponse.create({
providerId,
modelId: hit.modelId,
@@ -90,7 +94,7 @@ export async function resolveModelInfo(
const resolved = await catalog.resolveModels(providerId).catch(() => undefined)
if (resolved?.ok) {
const hit = pickFromCatalog(resolved, requestedModelId, allowCustomModelIds)
if (hit) {
if (hit && (hit.matchedRequested || !fallbackSelection)) {
return ResolveModelInfoResponse.create({
providerId,
modelId: hit.modelId,
@@ -100,6 +104,15 @@ export async function resolveModelInfo(
}
}
if (fallbackSelection) {
return ResolveModelInfoResponse.create({
providerId,
modelId: fallbackSelection.modelId,
modelInfo: toProtobufModelInfo(fallbackSelection.modelInfo),
source: "committed-selection",
})
}
return ResolveModelInfoResponse.create({
providerId,
modelId: requestedModelId,
@@ -25,6 +25,7 @@ export async function getStateToPostToWebview(controller: {
mcpHub?: any
backgroundCommandRunning?: boolean
backgroundCommandTaskId?: string
foregroundCommandRunning?: boolean
workspaceManager?: any
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
}): Promise<ExtensionState> {
@@ -157,6 +158,7 @@ export async function getStateToPostToWebview(controller: {
favoritedModelIds,
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
backgroundCommandTaskId: controller.backgroundCommandTaskId,
foregroundCommandRunning: controller.foregroundCommandRunning ?? false,
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
primaryRootIndex: controller.workspaceManager?.getPrimaryIndex?.() ?? 0,
isMultiRootWorkspace: (controller.workspaceManager?.getRoots?.()?.length ?? 0) > 1,
@@ -0,0 +1,15 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* "Proceed While Running": detach the in-flight foreground terminal command(s)
* so the agent turn continues with the partial output while the commands keep
* running in the user's terminal, streaming further output to a log file.
*/
export async function proceedWhileRunningCommand(controller: Controller, _request: EmptyRequest): Promise<Empty> {
const controllerWithProceed = controller as Controller & {
proceedWhileRunningCommand: () => Promise<void>
}
await controllerWithProceed.proceedWhileRunningCommand()
return Empty.create()
}
+5 -4
View File
@@ -15,8 +15,9 @@ Designed to be driven from an agentic loop via `curl` commands.
## Quick Start
```bash
# Terminal 1: Start the debug harness server
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 1: Start the debug harness server.
# Run with node, NOT bun — Playwright's Electron launch times out under bun.
node src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 2: Interact via curl
curl localhost:19229/api -d '{"method":"status"}'
@@ -27,7 +28,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
## Server Options
```
bun src/dev/debug-harness/server.ts [options]
node src/dev/debug-harness/server.ts [options]
Options:
--skip-build Skip building extension/webview (use existing dist/)
@@ -42,7 +43,7 @@ Options:
```bash
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
bun src/dev/debug-harness/server.ts --auto-launch
node src/dev/debug-harness/server.ts --auto-launch
```
## Data Isolation
+15 -9
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env node
/**
* Debug Harness Server
@@ -10,7 +10,12 @@
* - UI automation (click, type, screenshot) via Playwright
*
* Usage:
* bun src/dev/debug-harness/server.ts [options]
* node src/dev/debug-harness/server.ts [options]
*
* Run with node, not bun: Playwright's _electron.launch() never finishes
* attaching to the debugee under bun (the Electron process starts, but the
* launch times out), while the same launch works under node. Node >= 22.6
* runs this file directly via type stripping.
*
* Options:
* --skip-build Skip building extension/webview
@@ -39,7 +44,6 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { downloadAndUnzipVSCode, SilentReporter } from "@vscode/test-electron"
import { _electron, type CDPSession, type ElectronApplication, type Frame, type Page } from "playwright"
import WebSocket from "ws"
const __script_dir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url))
@@ -201,19 +205,21 @@ class CdpClient {
async connect(wsUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
// The runtime's built-in WebSocket (browser-style events), so the
// harness has no dependency on the `ws` package.
const ws = new WebSocket(wsUrl)
ws.on("open", () => {
ws.addEventListener("open", () => {
this.ws = ws
resolve()
})
ws.on("error", (e: Error) => {
if (!this.ws) reject(e)
ws.addEventListener("error", () => {
if (!this.ws) reject(new Error(`WebSocket connection failed: ${wsUrl}`))
})
ws.on("close", () => {
ws.addEventListener("close", () => {
this.ws = null
})
ws.on("message", (raw: WebSocket.Data) => {
const msg = JSON.parse(raw.toString())
ws.addEventListener("message", (event: MessageEvent) => {
const msg = JSON.parse(typeof event.data === "string" ? event.data : Buffer.from(event.data).toString())
if (msg.id !== undefined) {
const p = this.pending.get(msg.id)
if (p) {
@@ -694,4 +694,41 @@ describe("TerminalProcess (Integration Tests)", () => {
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
processAny.buffer.should.equal("")
})
it("detach emits continue but keeps line listeners attached and listening", () => {
const processAny = process as any
const continueEvents: number[] = []
const lines: string[] = []
process.on("continue", () => continueEvents.push(1))
process.on("line", (line) => lines.push(line))
process.detach()
continueEvents.length.should.equal(1)
// Unlike continue(), detach must not stop listening or drop 'line'
// listeners: output after detach still reaches subscribers (this is
// what streams the rest of a detached command to the log file).
processAny.isListening.should.be.true()
processAny.emitIfEol("after detach\n")
lines.should.containEql("after detach")
})
it("detach flushes a buffered partial line before emitting continue", () => {
const processAny = process as any
const events: string[] = []
process.on("continue", () => events.push("continue"))
process.on("line", (line) => events.push(`line:${line}`))
// A chunk with no trailing newline stays in the internal buffer.
processAny.emitIfEol("partial output")
processAny.buffer.should.equal("partial output")
process.detach()
// The partial line must reach listeners before 'continue' resolves the
// awaited promise; otherwise it is missing from the partial output and
// from the log's initial flush.
events.should.eql(["line:partial output", "continue"])
processAny.buffer.should.equal("")
})
})
@@ -16,8 +16,8 @@ import {
TRUNCATE_KEEP_LINES,
} from "@/integrations/terminal/constants"
import type { ITerminalProcess, TerminalCompletionDetails, TerminalProcessEvents } from "@/integrations/terminal/types"
import { Logger } from "@/shared/services/Logger"
import type { MarkerlessCompletionCause } from "@/services/telemetry/TelemetryService"
import { Logger } from "@/shared/services/Logger"
import { Osc633EventType, Osc633Parser } from "./osc633Parser"
import { classifyShellPrompt, getLastLine } from "./shellPromptHeuristics"
@@ -522,6 +522,23 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
this.emit("continue")
}
/**
* Resolve the awaited promise while the command keeps running ("Proceed
* While Running"). Listeners stay attached and 'line' events keep
* flowing unlike continue() so callers can stream the remaining
* output until the command actually completes. Because 'completed' is
* only emitted by the read loop when the command genuinely ends, the
* terminal stays busy and is not eligible for reuse until then.
*/
detach() {
// Flush any partial line (no trailing newline yet) so it reaches
// listeners before the awaited promise resolves; otherwise it would be
// dropped from both the partial output and the log capture if the
// command exits without further newline-terminated output.
this.emitRemainingBufferIfListening()
this.emit("continue")
}
/**
* Get output that hasn't been retrieved yet.
* Truncates if output is too large to prevent context window overflow.
@@ -63,10 +63,17 @@ export interface ITerminalProcess extends EventEmitter<TerminalProcessEvents> {
/**
* Continue execution without waiting for completion.
* Stops event emission and resolves the promise.
* This is called when user clicks "Proceed While Running".
*/
continue(): void
/**
* Resolve the awaited promise while the command keeps running ("Proceed
* While Running"). Unlike continue(), output listeners stay attached and
* 'line'/'completed' events keep flowing, so callers can stream the rest
* of the output (e.g. to a log file) until the command completes.
*/
detach(): void
/**
* Get output that hasn't been retrieved yet.
* @returns The unretrieved output
+25
View File
@@ -68,6 +68,7 @@ import {
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
import { SdkDiffEditCoordinator } from "./sdk-diff-edit-coordinator"
import { SdkFollowupCoordinator } from "./sdk-followup-coordinator"
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
import { SdkMessageCoordinator, type SessionEventListener } from "./sdk-message-coordinator"
@@ -204,6 +205,15 @@ export class Controller {
// standalone (JetBrains/CLI) host run commands through the SDK's built-in tool.
private _terminalManager?: VscodeTerminalManager
// Registry of in-flight foreground (VS Code terminal) command executions.
// Owned here — not by the session — so it survives session rebuilds, which
// recreate the tool set. Drives the "Proceed While Running" button.
private readonly foregroundCommands = new SdkForegroundCommandCoordinator({
onRunningChanged: () => {
void this.postStateToWebview()
},
})
// Private state kept for stub compatibility
private backgroundCommandRunning = false
private backgroundCommandTaskId?: string
@@ -330,6 +340,7 @@ export class Controller {
},
onDidBecomeIdle: () => this.handleSessionBecameIdle(),
getRemoteConfigIntegration: () => this.remoteConfigCoreIntegration,
foregroundCommands: this.foregroundCommands,
getTerminalManager: () => {
// Guarded by getEffectiveTerminalExecutionMode() at the read sites
// (vscode-session-host.ts, sdk-terminal-execution-mode-coordinator.ts):
@@ -1174,6 +1185,19 @@ export class Controller {
stubWarn("cancelBackgroundCommand")
}
/**
* "Proceed While Running": detach every in-flight foreground terminal
* command. Each pending run_commands call returns its partial output plus
* the log file path the remaining output is redirected to, and the agent
* turn continues while the commands keep running in their terminals.
*/
async proceedWhileRunningCommand(): Promise<void> {
const detached = this.foregroundCommands.proceedWhileRunning()
if (detached === 0) {
Logger.warn("[SdkController] proceedWhileRunningCommand: No foreground command is running")
}
}
async cancelQueuedPrompt(promptId: string): Promise<void> {
const trimmedPromptId = promptId.trim()
if (!trimmedPromptId) {
@@ -1868,6 +1892,7 @@ export class Controller {
mcpHub: this.mcpHub,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
foregroundCommandRunning: this.foregroundCommands.isRunning,
})
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
.map(sessionHistoryRecordToHistoryItem)
@@ -2,6 +2,9 @@ import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import type { CoreSessionConfig } from "@cline/core"
import * as LlmsModels from "@cline/llms"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
buildResumeSessionInput,
@@ -15,9 +18,12 @@ import {
resolveApiKey,
updateHistoryItem,
} from "./cline-session-factory"
import { parseProviderId } from "./model-catalog/provider-id"
import { createProviderConfigStore } from "./model-catalog/store"
const mocks = vi.hoisted(() => {
const providerSettingsManager = {
getFilePath: vi.fn(() => path.join(tempDir, "settings", "providers.json")),
getLastUsedProviderSettings: vi.fn(() => undefined),
getProviderSettings: vi.fn((_providerId?: string) => undefined),
saveProviderSettings: vi.fn(),
@@ -39,6 +45,9 @@ const mocks = vi.hoisted(() => {
}
return undefined
}),
setGlobalStateBatch: vi.fn(),
setGlobalState: vi.fn(),
setSecret: vi.fn(),
},
}
})
@@ -72,11 +81,14 @@ vi.mock("@shared/services/Logger", () => ({
let tempDir: string
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
const previousDataDir = process.env.CLINE_DATA_DIR
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
process.env.CLINE_DATA_DIR = tempDir
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
vi.clearAllMocks()
LlmsModels.resetRegistry()
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
@@ -88,12 +100,14 @@ beforeEach(() => {
}
return undefined
})
mocks.providerSettingsManager.getFilePath.mockReturnValue(path.join(tempDir, "settings", "providers.json"))
mocks.providerSettingsManager.getLastUsedProviderSettings.mockReturnValue(undefined)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
})
afterEach(() => {
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
process.env.CLINE_DATA_DIR = previousDataDir
fs.rmSync(tempDir, { recursive: true, force: true })
})
@@ -135,6 +149,11 @@ describe("getDefaultModelIdForProvider", () => {
expect(getDefaultModelIdForProvider("unknown-provider")).toBeUndefined()
})
it("returns no default for local-model-source providers so a cloud-catalog model is never silently selected", () => {
expect(getDefaultModelIdForProvider("ollama")).toBeUndefined()
expect(getDefaultModelIdForProvider("lmstudio")).toBeUndefined()
})
it("resolves the OpenAI Compatible default through the extension's openai alias", () => {
// The extension stores the OpenAI Compatible provider as "openai" while
// the SDK catalog keys it as "openai-compatible". toSdkProviderId bridges
@@ -230,9 +249,11 @@ describe("normalizeSdkBaseUrl", () => {
expect(normalizeSdkBaseUrl("openai-compatible", " ")).toBeUndefined()
})
it("uses provider catalog defaults to add the SDK endpoint path when the user supplies only an origin", () => {
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434/v1")
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/v1")
it("passes Ollama origins through unchanged (the native-API vendor appends /api itself)", () => {
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434")
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/")
// Legacy 4.0.x configs may carry the OpenAI-compat /v1 suffix; it is
// preserved here and rewritten to /api by the vendor.
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/v1")).toBe("http://localhost:11434/v1")
})
@@ -429,6 +450,40 @@ describe("buildSessionConfig", () => {
expect(config.providerConfig).not.toHaveProperty("apiKey")
})
it("preserves rich SDK catalog entries without extension-side replacement", async () => {
const expectedModel = structuredClone((await LlmsModels.getModelsForProvider("anthropic"))["claude-sonnet-4-6"])
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
apiKey: "anthropic-key",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["claude-sonnet-4-6"]
expect(knownModel).toEqual(expectedModel)
expect(knownModel.capabilities).toEqual(
expect.arrayContaining(["images", "files", "tools", "reasoning", "structured_output", "temperature", "prompt-cache"]),
)
expect(knownModel.pricing).toEqual(expectedModel.pricing)
expect(knownModel.releaseDate).toBe(expectedModel.releaseDate)
expect(knownModel.family).toBe(expectedModel.family)
})
it("keeps session creation non-fatal when known-model lookup fails", async () => {
const lookupError = new Error("registry unavailable")
const getModelsSpy = vi.spyOn(LlmsModels, "getModelsForProvider").mockRejectedValueOnce(lookupError)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(config.providerConfig).not.toHaveProperty("knownModels")
expect(Logger.warn).toHaveBeenCalledWith(
"[SessionFactory] Failed to resolve known models for provider=anthropic:",
lookupError,
)
getModelsSpy.mockRestore()
})
it("passes OpenAI Compatible max output tokens as an explicit request limit", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
@@ -451,11 +506,87 @@ describe("buildSessionConfig", () => {
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect(config.knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeDefined()
expect((config.providerConfig as any).maxOutputTokens).toBeUndefined()
expect((config as any).maxTokensPerTurn).toBe(4_096)
})
it("uses OpenAI Compatible overrides from models.json for runtime request settings", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
actModeOpenAiModelInfo: { supportsPromptCache: false },
} as any)
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
providerId: parseProviderId("openai"),
modelId: "custom-reasoner",
overrides: {
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 15_000,
maxTokens: 1_234,
capabilities: ["images", "reasoning", "streaming", "tools"],
supportsVision: false,
supportsAttachments: true,
supportsReasoning: false,
temperature: 0,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.5,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect((config as any).maxTokensPerTurn).toBe(1_234)
expect((config as any).temperature).toBe(0)
expect(knownModel).toMatchObject({
id: "custom-reasoner",
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 15_000,
maxTokens: 1_234,
capabilities: ["streaming", "tools", "files"],
apiFormat: "openai-responses",
temperature: 0,
pricing: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.5 },
})
})
it("keeps -1 OpenAI Compatible values out of request settings and fallback knownModels", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
actModeOpenAiModelInfo: { supportsPromptCache: false },
} as any)
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
providerId: parseProviderId("openai"),
modelId: "custom-reasoner",
overrides: {
name: "Custom Reasoner",
maxTokens: -1,
temperature: -1,
},
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect((config as any).maxTokensPerTurn).toBeUndefined()
expect((config as any).temperature).toBeUndefined()
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
expect(knownModel).not.toHaveProperty("maxTokens")
expect(knownModel).not.toHaveProperty("temperature", -1)
})
it("passes OCA reasoning effort from legacy mode settings to SDK sessions", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "oca",
+177 -6
View File
@@ -17,7 +17,13 @@ import {
resolveProviderApiKeyFromSettings,
type StartSessionResult,
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import type { ModelInfo as SdkModelInfo } from "@cline/llms"
import {
getGeneratedModelsForProvider,
getModelsForProvider,
MODEL_COLLECTIONS_BY_PROVIDER_ID,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
} from "@cline/llms"
import { buildClineSystemPrompt } from "@cline/shared"
import type { ApiConfiguration } from "@shared/api"
import { ClineClient } from "@shared/cline"
@@ -37,7 +43,11 @@ import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
import { buildAgentHooks } from "./hooks-adapter"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
import type { ResolvedModelSelection } from "./model-catalog/contracts"
import { nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-catalog/model-values"
import { parseProviderId } from "./model-catalog/provider-id"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { createProviderConfigStore, resolveRuntimeModelSelection } from "./model-catalog/store"
import { getProviderSettingsManager } from "./provider-migration"
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
import type { SdkSessionHost } from "./session-host"
@@ -209,8 +219,73 @@ function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | und
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
const maxTokens = modelInfo?.maxTokens
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
return positiveFiniteNumber(modelInfo?.maxTokens)
}
function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
const modelInfo = selection.modelInfo
const capabilities = new Set<NonNullable<SdkModelInfo["capabilities"]>[number]>(
(selection.overrides?.capabilities ?? []) as NonNullable<SdkModelInfo["capabilities"]>,
)
const setCapability = (capability: NonNullable<SdkModelInfo["capabilities"]>[number], enabled: boolean): void => {
if (enabled) capabilities.add(capability)
else capabilities.delete(capability)
}
if (modelInfo.supportsImages !== undefined) setCapability("images", modelInfo.supportsImages)
setCapability("prompt-cache", modelInfo.supportsPromptCache)
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
const maxTokens = positiveFiniteNumber(modelInfo.maxTokens)
const contextWindow = positiveFiniteNumber(modelInfo.contextWindow)
const maxInputTokens = positiveFiniteNumber(selection.overrides?.maxInputTokens)
const temperature = nonNegativeFiniteNumber(modelInfo.temperature)
const inputPrice = nonNegativeFiniteNumber(modelInfo.inputPrice)
const outputPrice = nonNegativeFiniteNumber(modelInfo.outputPrice)
const cacheRead = nonNegativeFiniteNumber(modelInfo.cacheReadsPrice)
const cacheWrite = nonNegativeFiniteNumber(modelInfo.cacheWritesPrice)
const apiFormat = toSdkApiFormat(modelInfo.apiFormat)
const hasPricing =
inputPrice !== undefined || outputPrice !== undefined || cacheRead !== undefined || cacheWrite !== undefined
return {
id: selection.modelId,
name: modelInfo.name ?? selection.modelId,
...(maxTokens !== undefined ? { maxTokens } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
...(capabilities.size > 0 ? { capabilities: [...capabilities] } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(hasPricing
? {
pricing: {
...(inputPrice !== undefined ? { input: inputPrice } : {}),
...(outputPrice !== undefined ? { output: outputPrice } : {}),
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheWrite !== undefined ? { cacheWrite } : {}),
},
}
: {}),
}
}
function resolveCommittedRuntimeModel(
providerId: string,
mode: Mode,
modelId: string | undefined,
): ResolvedModelSelection | undefined {
if (!modelId) {
return undefined
}
try {
const parsedProviderId = parseProviderId(providerId)
const selection = createProviderConfigStore().readSelection(parsedProviderId, mode)
return selection?.modelId === modelId ? selection : resolveRuntimeModelSelection(parsedProviderId, modelId)
} catch (error) {
Logger.warn(`[SessionFactory] Failed to resolve committed model settings for provider=${providerId}:`, error)
return undefined
}
}
// ---------------------------------------------------------------------------
@@ -303,8 +378,21 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
const DEFAULT_PROVIDER_ID = "cline"
/**
* Providers whose model list comes from a live local endpoint (Ollama's
* `/api/tags`, LM Studio's `/v1/models`). Their installed models are the only
* meaningful catalog; a bundled-catalog default would silently select a model
* the user never installed (e.g. an Ollama Cloud nemotron model).
*/
function providerHasLocalModelSource(providerId: string): boolean {
return Boolean(MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.modelsSourceUrl)
}
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
const sdkProviderId = toSdkProviderId(providerId)
if (providerHasLocalModelSource(providerId)) {
return undefined
}
const collection = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]
if (!collection) {
return undefined
@@ -479,6 +567,42 @@ export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<Prov
}
}
type OllamaProviderConfig = {
modelInfo?: { id: string; name: string; contextWindow: number }
timeoutMs?: number
}
/**
* Resolve the user's "Model Context Window" setting for Ollama and surface it
* as the selected model's `contextWindow`. The gateway carries it on the
* resolved model definition, and the Ollama vendor maps it onto the wire as
* `options.num_ctx` without it Ollama loads every model with its 4096-token
* server default. Keeping it on the model also means context management
* budgets against the window Ollama actually applies (Ollama truncates the
* prompt to `num_ctx` server-side).
*/
export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: string | undefined): OllamaProviderConfig {
// providers.json (`contextWindow`) is the source of truth; the legacy
// StateManager string is a migration fallback (the config store mirrors
// writes to both).
let settingsContextWindow: number | undefined
try {
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
settingsContextWindow = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
} catch {
Logger.warn("[SessionFactory] Failed to read Ollama settings from providers.json")
}
const raw = config.ollamaApiOptionsCtxNum?.trim()
const parsed = raw ? Number(raw) : Number.NaN
const legacyContextWindow = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined
const contextWindow = settingsContextWindow ?? legacyContextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW
const timeoutMs = config.requestTimeoutMs
return {
...(typeof timeoutMs === "number" && timeoutMs > 0 ? { timeoutMs } : {}),
...(modelId ? { modelInfo: { id: modelId, name: modelId, contextWindow } } : {}),
}
}
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
anthropic: "anthropicBaseUrl",
@@ -536,6 +660,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
let bedrockProviderConfig: BedrockProviderConfig | undefined
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
let sapProviderConfig: SapProviderConfig | undefined
let ollamaProviderConfig: ReturnType<typeof resolveOllamaProviderConfig> | undefined
try {
const stateManager = StateManager.get()
@@ -571,6 +696,10 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
baseUrl = sapProviderConfig.baseUrl
}
if (providerId === "ollama") {
ollamaProviderConfig = resolveOllamaProviderConfig(apiConfig, modelId)
}
Logger.log(
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
)
@@ -606,12 +735,31 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// Final defaults. Keep this aligned with the provider catalog so the UI and
// session factory share one source of truth for default models.
providerId = providerId ?? DEFAULT_PROVIDER_ID
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
if (!modelId && providerHasLocalModelSource(providerId)) {
// Local-model-source providers: the committed selection lives in
// providers.json when the legacy state slot is empty (e.g. configs
// created through the SDK settings store). Never fall through to a
// catalog default — an empty model id surfaces an explicit "select a
// model" state instead of silently running a model the user never chose.
try {
modelId = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))?.model?.trim()
} catch {
Logger.warn(`[SessionFactory] Failed to read ${providerId} model from providers.json`)
}
modelId = modelId || ""
} else {
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
}
if (!apiKey && apiConfig) {
apiKey = resolveApiKey(providerId, apiConfig)
}
apiKey = apiKey ?? ""
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
const committedRuntimeModel = resolveCommittedRuntimeModel(providerId, mode, modelId)
const overriddenMaxTokens = committedRuntimeModel?.overrides?.maxTokens
const maxTokensPerTurn =
positiveFiniteNumber(overriddenMaxTokens) ??
(providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined)
const temperature = nonNegativeFiniteNumber(committedRuntimeModel?.overrides?.temperature)
const reasoningConfig =
providerId === "oca"
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
@@ -662,13 +810,34 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
const sdkProviderId = toSdkProviderId(providerId)
const hostIdentity = await resolveHostIdentity()
const isMultiRoot = await resolveIsMultiRootWorkspace()
let knownModels: Awaited<ReturnType<typeof getModelsForProvider>> | undefined
try {
// Constructing the settings manager loads providers.json and models.json into
// the @cline/llms registry. Reading models from that registry ensures custom
// model overrides are included in the inference provider config, not just in
// the webview/display path.
getProviderSettingsManager(resolveDataDir())
knownModels = await getModelsForProvider(sdkProviderId)
// Only inject host-resolved metadata that carries real information
// (catalog/state base or user overrides). Pure fallback fabrications
// must not reach the runtime; the SDK's own resolution handles those.
const isPureFallbackModel = committedRuntimeModel?.modelInfoSource === "fallback" && !committedRuntimeModel.overrides
if (committedRuntimeModel && !isPureFallbackModel && !knownModels?.[modelId]) {
knownModels = {
...(knownModels ?? {}),
[modelId]: toSdkModelInfo(committedRuntimeModel),
}
}
} catch (error) {
Logger.warn(`[SessionFactory] Failed to resolve known models for provider=${sdkProviderId}:`, error)
}
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
// gateway; without it the agent loop uses bare global fetch and corporate
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
// additionally need structured options (region/project/auth/SAP OAuth), which core
// reads from providerConfig in createAgentModelFromConfig.
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig ?? ollamaProviderConfig
// Spread the cloud config first so the explicit fields below — notably the
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
const providerConfig = {
@@ -677,6 +846,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
modelId,
...(apiKey ? { apiKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
...(knownModels && Object.keys(knownModels).length > 0 ? { knownModels } : {}),
fetch,
}
@@ -707,6 +877,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
mode: mode === "plan" ? "plan" : "act",
...reasoningConfig,
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
...(temperature !== undefined ? { temperature } : {}),
maxIterations: undefined,
logger: sdkLogger,
extensionContext: {
@@ -3,10 +3,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
import type {
EffectiveProviderConfig,
Fingerprint,
ModelSelection,
ProviderConfigChange,
ProviderConfigReader,
ProviderModelsResult,
ResolvedModelSelection,
} from "./contracts"
import { computeConfigFingerprint } from "./fingerprint"
import { parseProviderId } from "./provider-id"
@@ -95,7 +95,7 @@ function record(
}
}
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ModelSelection): TestReader {
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ResolvedModelSelection): TestReader {
let config = initialConfig
const listeners = new Set<(event: ProviderConfigChange) => void>()
return {
@@ -241,7 +241,7 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
const providerId = parseProviderId("openrouter")
const config: EffectiveProviderConfig = { providerId, apiKey: "secret", baseUrl: "https://provider.example.com" }
const selection: ModelSelection = { providerId, modelId: "selected", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "selected", modelInfo }
const reader = makeReader(config, selection)
const catalog = createProviderCatalog(reader)
@@ -507,7 +507,7 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
const providerId = parseProviderId("openrouter")
const reader = makeReader({ providerId, apiKey: "same" })
const catalog = createProviderCatalog(reader)
const selection: ModelSelection = { providerId, modelId: "different", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "different", modelInfo }
const first = await catalog.resolveModels(providerId)
reader.emit({ kind: "selection", providerId, mode: "act", selection })
@@ -692,7 +692,7 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
const catalog = createProviderCatalog(reader)
const listener = vi.fn()
const selection: ModelSelection = { providerId, modelId: "custom:latest", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "custom:latest", modelInfo }
catalog.subscribe(providerId, listener)
reader.emit({ kind: "selection", providerId, mode: "act", selection })
+68 -17
View File
@@ -68,8 +68,8 @@ export type Fingerprint = string & { readonly [FingerprintBrand]: void }
* - Two reads with no intervening write return structurally equal values.
* - Consumers must not mutate. The shape is `Readonly`.
*
* Mode-dependent selection (modelId, modelInfo) is *not* part of this
* type. Use `ProviderConfigStore.readSelection(providerId, mode)`.
* Mode-dependent selection is *not* part of this type. Use
* `ProviderConfigStore.readSelection(providerId, mode)`.
*/
export interface AwsProviderConfig {
readonly accessKey?: string
@@ -98,6 +98,12 @@ export interface EffectiveProviderConfig {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
/**
* Provider-level context window (providers.json `contextWindow`).
* Provider-neutral: for Ollama it maps to `options.num_ctx` at the
* vendor boundary.
*/
readonly contextWindow?: number
/**
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
* Compatible with `apiKey`; some providers populate both.
@@ -119,9 +125,9 @@ export interface EffectiveProviderConfig {
* A patch describing a field-level write to `ProviderConfigStore`.
*
* Invariant: `ProviderConfigPatch` cannot describe a model selection. The
* type does not contain `modelId` or `modelInfo`. To write a selection,
* use `commitSelection`, which is a structurally distinct method on the
* store.
* type does not contain `modelId` or per-model overrides. To write a
* selection, use `commitSelection`, which is a structurally distinct method
* on the store.
*
* Empty patches are allowed and are no-ops. A field present with value
* `null` means "clear this field"; an absent field means "leave unchanged."
@@ -140,6 +146,7 @@ export interface ProviderConfigPatch {
readonly region?: string | null
readonly aws?: AwsProviderConfig | null
readonly gcp?: GcpProviderConfig | null
readonly contextWindow?: number | null
readonly auth?: {
readonly accessToken?: string
readonly refreshToken?: string
@@ -153,22 +160,65 @@ export interface ProviderConfigPatch {
// Model selection
// ---------------------------------------------------------------------------
/** Per-model metadata overrides authored by the user for custom models. */
export interface ModelSelectionOverrides {
readonly name?: string
readonly maxTokens?: number
readonly contextWindow?: number
readonly maxInputTokens?: number
readonly capabilities?: readonly string[]
readonly supportsVision?: boolean
readonly supportsAttachments?: boolean
readonly supportsReasoning?: boolean
readonly inputPrice?: number
readonly outputPrice?: number
readonly cacheReadsPrice?: number
readonly cacheWritesPrice?: number
readonly temperature?: number
readonly apiFormat?: ModelInfo["apiFormat"]
readonly isR1FormatRequired?: boolean
}
/**
* A user's committed model selection. The triple is atomic by type: every
* write of `modelId` carries its `modelInfo` envelope, and vice versa.
* A user's committed model selection. The committed write stores only the
* selected model id plus optional user-authored overrides. `ModelInfo` is
* derived by the host from the SDK catalog, then layered with `models.json`
* overrides, then per-provider safe fallback metadata.
*
* Invariants:
* - `modelInfo` was either taken from a `ProviderCatalog.resolveModels`
* result, or constructed from per-provider safe defaults when the user
* entered a custom id manually. Either way it represents the picker's
* best knowledge at the moment of commit. The runtime uses it verbatim.
* - The stored selection wins over later SDK catalog changes. Refresh
* does not retroactively change committed selections.
* - The webview does not commit a `ModelInfo` snapshot.
* - Catalog metadata updates may affect the resolved `ModelInfo` for an
* existing selection unless the user has explicitly overridden the field.
*/
export interface ModelSelection {
readonly providerId: ProviderId
readonly modelId: string
readonly overrides?: ModelSelectionOverrides
}
/** A committed selection as read back by consumers that need display/runtime metadata. */
export interface ResolvedModelSelection extends ModelSelection {
readonly modelInfo: ModelInfo
/**
* Where the base `modelInfo` came from, before overrides were applied:
*
* - "catalog" SDK catalog metadata (generated snapshot or registry).
* - "state" the mode-specific `*ModeModelInfo` snapshot persisted by
* the picker at selection time. Authoritative for dynamic-list providers
* (openrouter, litellm, requesty, ) whose models are not in the static
* catalog.
* - "fallback" provider-safe defaults fabricated because nothing better
* was available. Consumers should treat a "fallback" resolution without
* overrides as weak data and prefer live catalog lookups over it.
*/
readonly modelInfoSource?: "catalog" | "state" | "fallback"
/**
* The base metadata `modelInfo` was resolved from, before overrides were
* applied. Persisted (rather than the resolved value) into the legacy
* state snapshot so that deleting an override cannot resurrect it from a
* snapshot it was previously baked into.
*/
readonly baseModelInfo?: ModelInfo
}
// ---------------------------------------------------------------------------
@@ -190,7 +240,7 @@ export type ProviderConfigChange =
readonly kind: "selection"
readonly providerId: ProviderId
readonly mode: Mode
readonly selection: ModelSelection
readonly selection: ResolvedModelSelection
}
export type ProviderConfigChangeListener = (event: ProviderConfigChange) => void
@@ -317,7 +367,7 @@ export interface ProviderModelsEvent {
*/
export interface ProviderConfigReader {
read(providerId: ProviderId): EffectiveProviderConfig
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined
subscribe(listener: ProviderConfigChangeListener): Disposable
}
@@ -351,8 +401,9 @@ export interface ProviderConfigStore extends ProviderConfigReader {
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig
/**
* Commit a model selection atomically with its info envelope. The only
* entry point that writes `{providerId, modelId, modelInfo}` triples.
* Commit a model ID atomically with optional user-authored overrides.
* Supplying overrides replaces that model's stored override entry; omitting
* them leaves the existing entry unchanged.
*
* I2: refresh handlers do not have access to this method by type, since
* `ProviderCatalog` holds only a `ProviderConfigReader`.
@@ -54,7 +54,27 @@ describe("buildEffectiveProviderConfig", () => {
providerId: parseProviderId("ollama"),
apiKey: "provider-ollama-key",
baseUrl: "http://state-ollama:11434",
extras: { ollamaApiOptionsCtxNum: "8192" },
// The legacy state string surfaces as the provider-neutral
// contextWindow when providers.json has none.
contextWindow: 8192,
})
})
it("prefers the providers.json contextWindow over the legacy Ollama state key", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
ollama: {
provider: "ollama",
contextWindow: 65536,
},
})
mocks.setApiConfiguration({
ollamaApiOptionsCtxNum: "8192",
})
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
providerId: parseProviderId("ollama"),
contextWindow: 65536,
})
})
@@ -18,6 +18,7 @@ type ProviderSettingsLike = {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly contextWindow?: number
readonly auth?: AuthConfig
readonly extras?: ExtrasConfig
}
@@ -102,7 +103,6 @@ const headerFields: Partial<Record<string, keyof ApiConfiguration>> = {
}
const extrasFields: Partial<Record<string, Partial<Record<string, keyof ApiConfiguration>>>> = {
ollama: { ollamaApiOptionsCtxNum: "ollamaApiOptionsCtxNum" },
lmstudio: { lmStudioMaxTokens: "lmStudioMaxTokens" },
litellm: { liteLlmUsePromptCache: "liteLlmUsePromptCache" },
openrouter: { openRouterProviderSorting: "openRouterProviderSorting" },
@@ -157,6 +157,14 @@ function readBoolean(record: Record<string, unknown>, key: string): boolean | un
return typeof value === "boolean" ? value : undefined
}
function readPositiveInteger(value: unknown): number | undefined {
const parsed = typeof value === "string" ? Number(value) : value
if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed)
}
return undefined
}
function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined {
const gcp = record.gcp
if (!isPlainRecord(gcp)) {
@@ -206,6 +214,7 @@ function readProviderSettings(providerId: ProviderId): ConfigParts {
region: readString(settings, "region"),
aws: readAws(settings),
gcp: readGcp(settings),
contextWindow: readPositiveInteger(settings.contextWindow),
auth: readAuth(settings),
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
} satisfies ProviderSettingsLike
@@ -298,6 +307,16 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
}
function readStateContextWindow(provider: string, config: ApiConfiguration): number | undefined {
// Only Ollama has a legacy context-window state key; other providers keep
// theirs in providers.json exclusively.
if (provider !== "ollama") {
return undefined
}
return readPositiveInteger(config.ollamaApiOptionsCtxNum)
}
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
const provider = providerId.toString()
return {
@@ -308,6 +327,7 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
region: readStringFromConfig(config, regionFields[provider]),
aws: readStateAws(provider, config),
gcp: readStateGcp(provider, config),
contextWindow: readStateContextWindow(provider, config),
auth: readStateAuth(provider, config),
extras: readStateExtras(provider, config),
}
@@ -372,6 +392,10 @@ export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveP
// fields as a fallback for old installs, but let providers.json win when both exist.
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
// providers.json is the source of truth for the context window; the legacy
// Ollama StateManager key is a migration fallback (the store mirrors writes
// to both).
assignIfDefined(merged, "contextWindow", providerSettings.contextWindow ?? stateConfig.contextWindow)
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
@@ -170,6 +170,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
region: config.region ?? null,
aws: sanitizeAws(config.aws),
gcp: sanitizeGcp(config.gcp),
// The context window is not a secret; include it raw so changes
// invalidate cached model lists.
contextWindow: config.contextWindow ?? null,
extras: sanitizeExtras(config.extras),
auth: {
accountId: config.auth?.accountId ?? null,
@@ -1,7 +1,8 @@
/**
* Applies the `ModelInfo` fields the extension owns locally, on top of
* an adapted SDK `ModelInfo`. Today this is just Vertex's
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`).
* an adapted SDK `ModelInfo`. Today this is Vertex's
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`)
* and Ollama's effective context window.
*
* Both the model-list resolution path (`resolveSdkModels`) and the
* single-model lookup path (`resolveModelInfo`) pass adapted
@@ -10,13 +11,52 @@
* flags upstream, the override and this file can be removed together.
*/
import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "@cline/llms"
import type { ModelInfo } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { ProviderId } from "./contracts"
import { vertexModelSupportsGlobalEndpoint } from "./vertex-global-endpoint"
/**
* The context window Ollama actually applies is the requested `num_ctx`,
* not the model's native maximum Ollama truncates the prompt to it
* server-side. Surface the user's "Model Context Window" setting (or the
* request default) instead of catalog/safe-default values so the chat
* indicator and context management match reality.
*/
function resolveOllamaContextWindow(): number {
// providers.json (`contextWindow`) is the source of truth; the legacy
// StateManager string is a migration fallback (the config store mirrors
// writes to both).
try {
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
} catch {
// providers.json unavailable — fall through to the legacy state key.
}
try {
const raw = StateManager.get().getApiConfiguration().ollamaApiOptionsCtxNum?.trim()
if (raw) {
const value = Number(raw)
if (Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
}
} catch {
// StateManager unavailable (e.g. tests) — fall through to the default.
}
return OLLAMA_DEFAULT_CONTEXT_WINDOW
}
export function applyHostModelInfoOverrides(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): ModelInfo {
if (providerId === "vertex" && vertexModelSupportsGlobalEndpoint(providerId, modelId)) {
return { ...modelInfo, supportsGlobalEndpoint: true }
}
if (providerId === "ollama") {
return { ...modelInfo, contextWindow: resolveOllamaContextWindow() }
}
return modelInfo
}
@@ -0,0 +1,45 @@
import type { ModelInfo } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
/** SDK string spelling of an API format (matches @cline/shared ApiFormatSchema). */
export type SdkApiFormatString = "r1" | "openai-responses" | "default"
export function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
export function positiveFiniteNumber(value: unknown): number | undefined {
const number = finiteNumber(value)
return number !== undefined && number > 0 ? number : undefined
}
export function nonNegativeFiniteNumber(value: unknown): number | undefined {
const number = finiteNumber(value)
return number !== undefined && number >= 0 ? number : undefined
}
export function toSdkApiFormat(apiFormat: ModelInfo["apiFormat"]): SdkApiFormatString | undefined {
switch (apiFormat) {
case ApiFormat.R1_CHAT:
return "r1"
case ApiFormat.OPENAI_RESPONSES:
return "openai-responses"
case ApiFormat.OPENAI_CHAT:
return "default"
default:
return undefined
}
}
export function fromSdkApiFormat(apiFormat: string | undefined): ModelInfo["apiFormat"] | undefined {
switch (apiFormat) {
case "r1":
return ApiFormat.R1_CHAT
case "openai-responses":
return ApiFormat.OPENAI_RESPONSES
case "default":
return ApiFormat.OPENAI_CHAT
default:
return undefined
}
}
+572 -29
View File
@@ -1,4 +1,6 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import { syncStoredProviderRegistration } from "@cline/core"
import { type ApiConfiguration, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { ProviderConfigChange } from "./contracts"
import { parseProviderId } from "./provider-id"
@@ -7,6 +9,11 @@ const mocks = vi.hoisted(() => {
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
let apiConfiguration: MockApiConfiguration = {}
let providerSettingsById: Record<string, Record<string, unknown>> = {}
let generatedModelsByProvider: Record<string, Record<string, ModelInfo>> = {}
let modelsFile: { version: 1; providers: Record<string, { models?: Record<string, Record<string, unknown>> }> } = {
version: 1,
providers: {},
}
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
const provider = settings.provider
if (typeof provider !== "string") {
@@ -20,6 +27,8 @@ const mocks = vi.hoisted(() => {
reset(): void {
apiConfiguration = {}
providerSettingsById = {}
generatedModelsByProvider = {}
modelsFile = { version: 1, providers: {} }
saveProviderSettings.mockClear()
},
setApiConfiguration(value: MockApiConfiguration): void {
@@ -28,6 +37,12 @@ const mocks = vi.hoisted(() => {
setProviderSettings(value: Record<string, Record<string, unknown>>): void {
providerSettingsById = { ...value }
},
setGeneratedModels(providerId: string, models: Record<string, ModelInfo>): void {
generatedModelsByProvider = { ...generatedModelsByProvider, [providerId]: models }
},
getGeneratedModels(providerId: string): Record<string, ModelInfo> {
return generatedModelsByProvider[providerId] ?? {}
},
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
return providerSettingsById[providerId]
},
@@ -37,6 +52,12 @@ const mocks = vi.hoisted(() => {
getSaveProviderSettingsMock(): typeof saveProviderSettings {
return saveProviderSettings
},
getModelsFile() {
return modelsFile
},
setModelsFile(value: typeof modelsFile): void {
modelsFile = value
},
getStateManager() {
return {
getApiConfiguration: () => ({ ...apiConfiguration }),
@@ -69,11 +90,24 @@ vi.mock("../provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
vi.mock("@cline/core", () => ({
syncStoredProviderRegistration: vi.fn(),
readModelsFileSync: vi.fn(() => mocks.getModelsFile()),
resolveModelsRegistryPath: vi.fn(() => "/tmp/models.json"),
writeModelsFileSync: vi.fn((_filePath: string, state: ReturnType<typeof mocks.getModelsFile>) => mocks.setModelsFile(state)),
}))
vi.mock("@cline/llms", () => ({
getGeneratedModelsForProvider: vi.fn((providerId: string) => mocks.getGeneratedModels(providerId)),
MODEL_COLLECTIONS_BY_PROVIDER_ID: {},
}))
const modelInfoA: ModelInfo = {
name: "Model A",
contextWindow: 128_000,
maxTokens: 8_192,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_RESPONSES,
}
const modelInfoB: ModelInfo = {
@@ -83,9 +117,41 @@ const modelInfoB: ModelInfo = {
supportsPromptCache: false,
}
function selectionFromModelInfo(providerId: ReturnType<typeof parseProviderId>, modelId: string, modelInfo: ModelInfo) {
const capabilities: string[] = []
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
if (modelInfo.supportsImages) capabilities.push("images")
if (modelInfo.supportsReasoning) capabilities.push("reasoning")
return {
providerId,
modelId,
overrides: {
name: modelInfo.name,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
...(modelInfo.apiFormat !== undefined ? { apiFormat: modelInfo.apiFormat } : {}),
...(capabilities.length > 0 ? { capabilities } : {}),
},
}
}
function expectResolvedSelection(
actual: unknown,
selection: ReturnType<typeof selectionFromModelInfo>,
modelInfo: ModelInfo,
): void {
expect(actual).toMatchObject({
providerId: selection.providerId,
modelId: selection.modelId,
overrides: selection.overrides,
modelInfo,
})
}
describe("createProviderConfigStore", () => {
beforeEach(() => {
mocks.reset()
vi.clearAllMocks()
vi.resetModules()
})
@@ -121,22 +187,29 @@ describe("createProviderConfigStore", () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "anthropic/claude-sonnet-4", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "anthropic/claude-sonnet-4", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getModelsFile().providers.openrouter?.models?.["anthropic/claude-sonnet-4"]).toMatchObject({
name: "Model A",
contextWindow: 128_000,
maxTokens: 8_192,
apiFormat: "openai-responses",
capabilities: ["prompt-cache"],
})
})
it("round-trips generic provider selections using the in-process modelInfo envelope", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("deepseek")
const selection = { providerId, modelId: "deepseek-v4-pro", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "deepseek-v4-pro", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
})
it("hydrates a generic provider selection from providers.json after reload", async () => {
@@ -148,6 +221,8 @@ describe("createProviderConfigStore", () => {
expect(store.readSelection(providerId, "act")).toEqual({
providerId,
modelId: "manual-zai-model",
modelInfoSource: "fallback",
baseModelInfo: expect.objectContaining({ name: "manual-zai-model" }),
modelInfo: expect.objectContaining({
name: "manual-zai-model",
supportsPromptCache: false,
@@ -160,27 +235,27 @@ describe("createProviderConfigStore", () => {
const store = createProviderConfigStore()
const geminiProviderId = parseProviderId("gemini")
const deepSeekProviderId = parseProviderId("deepseek")
const geminiSelection = { providerId: geminiProviderId, modelId: "gemini-3.1-pro-preview", modelInfo: modelInfoA }
const deepSeekSelection = { providerId: deepSeekProviderId, modelId: "deepseek-v4-pro", modelInfo: modelInfoB }
const geminiSelection = selectionFromModelInfo(geminiProviderId, "gemini-3.1-pro-preview", modelInfoA)
const deepSeekSelection = selectionFromModelInfo(deepSeekProviderId, "deepseek-v4-pro", modelInfoB)
store.commitSelection(geminiProviderId, "act", geminiSelection)
store.commitSelection(deepSeekProviderId, "act", deepSeekSelection)
expect(store.readSelection(geminiProviderId, "act")).toEqual(geminiSelection)
expect(store.readSelection(deepSeekProviderId, "act")).toEqual(deepSeekSelection)
expectResolvedSelection(store.readSelection(geminiProviderId, "act"), geminiSelection, modelInfoA)
expectResolvedSelection(store.readSelection(deepSeekProviderId, "act"), deepSeekSelection, modelInfoB)
})
it("handles normalized nousResearch provider casing for writes and selections", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("nousResearch")
const selection = { providerId, modelId: "nousresearch/hermes-4-70b", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "nousresearch/hermes-4-70b", modelInfoA)
const written = store.write(providerId, { apiKey: "nous-key" })
store.commitSelection(providerId, "act", selection)
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
apiKey: "nous-key",
@@ -227,6 +302,179 @@ describe("createProviderConfigStore", () => {
})
})
it("lazily migrates meaningful legacy custom-model metadata once", async () => {
const legacyModelInfo = {
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: true,
}
mocks.setApiConfiguration({
actModeOpenAiModelId: "legacy-custom",
actModeOpenAiModelInfo: legacyModelInfo,
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const first = store.readSelection(providerId, "act")
const second = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["legacy-custom"]).toEqual({
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
capabilities: ["prompt-cache"],
supportsVision: false,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: "openai-responses",
isR1FormatRequired: true,
})
expect(first?.overrides).toEqual(second?.overrides)
expect(first?.modelInfo).toMatchObject({
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: ApiFormat.R1_CHAT,
})
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
})
it("does not create migration noise for legacy safe defaults", async () => {
mocks.setApiConfiguration({
actModeOpenAiModelId: "default-custom",
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, name: "default-custom" },
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const first = store.readSelection(providerId, "act")
const second = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["default-custom"]).toBeUndefined()
expect(first?.overrides).toBeUndefined()
expect(second?.overrides).toBeUndefined()
expect(first?.modelInfo).toMatchObject({ contextWindow: 128_000, supportsImages: true, temperature: 0 })
expect(first?.modelInfo.maxTokens).toBeUndefined()
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("never overwrites an existing models.json entry during migration", async () => {
mocks.setModelsFile({
version: 1,
providers: {
"openai-compatible": { models: { "existing-custom": { temperature: 0.7 } } },
},
})
mocks.setApiConfiguration({
actModeOpenAiModelId: "existing-custom",
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, temperature: 0.2 },
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["existing-custom"]).toEqual({
temperature: 0.7,
})
expect(selection?.overrides).toEqual({ temperature: 0.7 })
expect(selection?.modelInfo.temperature).toBe(0.7)
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("does not migrate stale legacy snapshots for catalog-known models", async () => {
mocks.setGeneratedModels("openai-compatible", {
"known-model": {
name: "Current Catalog Model",
contextWindow: 256_000,
supportsPromptCache: false,
temperature: 0.1,
},
})
mocks.setApiConfiguration({
actModeOpenAiModelId: "known-model",
actModeOpenAiModelInfo: {
name: "Stale Catalog Model",
contextWindow: 32_000,
supportsPromptCache: false,
temperature: 0.9,
},
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["known-model"]).toBeUndefined()
expect(selection?.overrides).toBeUndefined()
expect(selection?.modelInfo).toMatchObject({
name: "Current Catalog Model",
contextWindow: 256_000,
temperature: 0.1,
})
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("migrates separate Plan and Act legacy custom models independently", async () => {
mocks.setApiConfiguration({
planActSeparateModelsSetting: true,
planModeOpenAiModelId: "legacy-plan",
planModeOpenAiModelInfo: {
...openAiModelInfoSafeDefaults,
contextWindow: 64_000,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
actModeOpenAiModelId: "legacy-act",
actModeOpenAiModelInfo: {
...openAiModelInfoSafeDefaults,
maxTokens: 2_048,
isR1FormatRequired: true,
},
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const plan = store.readSelection(providerId, "plan")
const act = store.readSelection(providerId, "act")
store.readSelection(providerId, "plan")
store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models).toMatchObject({
"legacy-plan": { contextWindow: 64_000, apiFormat: "openai-responses" },
"legacy-act": { maxTokens: 2_048, isR1FormatRequired: true },
})
expect(plan?.modelInfo).toMatchObject({ contextWindow: 64_000, apiFormat: ApiFormat.OPENAI_RESPONSES })
expect(act?.modelInfo).toMatchObject({ maxTokens: 2_048, apiFormat: ApiFormat.R1_CHAT })
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
})
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
@@ -237,7 +485,7 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "gpt-oss-120b", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -248,6 +496,242 @@ describe("createProviderConfigStore", () => {
})
})
it("preserves per-model OpenAI Compatible overrides when switching models without new overrides", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const modelASelection = selectionFromModelInfo(providerId, "model-a", modelInfoA)
store.commitSelection(providerId, "act", modelASelection)
store.commitSelection(providerId, "act", { providerId, modelId: "model-b" })
store.commitSelection(providerId, "act", { providerId, modelId: "model-a" })
expectResolvedSelection(store.readSelection(providerId, "act"), modelASelection, modelInfoA)
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["model-a"]).toMatchObject({
name: "Model A",
maxTokens: 8_192,
contextWindow: 128_000,
})
})
it("deletes a model entry when an explicit replacement override set is empty", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.2,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toMatchObject({
apiFormat: "openai-responses",
capabilities: ["tools", "streaming"],
temperature: 0.2,
})
store.commitSelection(providerId, "act", { providerId, modelId: "custom-model", overrides: {} })
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
})
it("replaces an existing model override set instead of merging stale fields", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { apiFormat: ApiFormat.OPENAI_RESPONSES, inputPrice: 1, temperature: 0.2 },
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { temperature: 0.4 },
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({ temperature: 0.4 })
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ temperature: 0.4 })
})
it.each([
[ApiFormat.OPENAI_CHAT, "default"],
[ApiFormat.R1_CHAT, "r1"],
[ApiFormat.OPENAI_RESPONSES, "openai-responses"],
] as const)("round-trips supported apiFormat %s through models.json", async (apiFormat, storedApiFormat) => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { apiFormat },
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
apiFormat: storedApiFormat,
})
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ apiFormat })
})
it("normalizes invalid override values before storage and resolved legacy state", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
name: "Custom model",
maxTokens: -1,
contextWindow: Number.POSITIVE_INFINITY,
maxInputTokens: 0,
capabilities: ["tools", "tools", "vision", "unknown"],
supportsVision: false,
supportsReasoning: false,
inputPrice: Number.NaN,
outputPrice: 2,
cacheReadsPrice: 0,
cacheWritesPrice: -1,
temperature: -1,
apiFormat: 999 as ApiFormat,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
name: "Custom model",
capabilities: ["tools"],
supportsVision: false,
supportsReasoning: false,
outputPrice: 2,
cacheReadsPrice: 0,
})
const selection = store.readSelection(providerId, "act")
expect(selection?.overrides).toEqual({
name: "Custom model",
capabilities: ["tools"],
supportsVision: false,
supportsReasoning: false,
outputPrice: 2,
cacheReadsPrice: 0,
})
expect(selection?.modelInfo.maxTokens).toBeUndefined()
expect(selection?.modelInfo.temperature).toBe(0)
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("maxTokens")
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("temperature", -1)
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
})
it("deletes a stored entry when normalization removes every replacement field", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { temperature: 0.2 },
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
maxTokens: -1,
contextWindow: 0,
capabilities: ["vision", "unknown"],
inputPrice: Number.NaN,
temperature: -1,
apiFormat: 999 as ApiFormat,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
})
it("normalizes invalid values already present in models.json on read", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": { provider: "openai-compatible", model: "custom-model" },
})
mocks.setModelsFile({
version: 1,
providers: {
"openai-compatible": {
models: {
"custom-model": {
maxTokens: -1,
contextWindow: 64_000,
inputPrice: -2,
temperature: -1,
capabilities: ["tools"],
},
},
},
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(selection?.overrides).toEqual({ contextWindow: 64_000, capabilities: ["tools"] })
expect(selection?.modelInfo.maxTokens).toBeUndefined()
expect(selection?.modelInfo.temperature).toBe(0)
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("lets explicit capability booleans win and applies the R1 alias deterministically", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["images", "prompt-cache", "reasoning"],
supportsVision: false,
supportsReasoning: false,
isR1FormatRequired: false,
},
})
let selection = store.readSelection(providerId, "act")
expect(selection?.modelInfo).toMatchObject({
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: false,
apiFormat: ApiFormat.OPENAI_RESPONSES,
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["prompt-cache"],
supportsVision: true,
isR1FormatRequired: true,
},
})
selection = store.readSelection(providerId, "act")
expect(selection?.modelInfo).toMatchObject({
supportsImages: true,
supportsPromptCache: true,
apiFormat: ApiFormat.R1_CHAT,
})
})
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
@@ -259,14 +743,14 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
const planSelection = selectionFromModelInfo(providerId, "plan-openai-model", modelInfoA)
const actSelection = selectionFromModelInfo(providerId, "act-openai-model", modelInfoB)
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "plan-openai-model",
planModeOpenAiModelInfo: modelInfoA,
@@ -286,12 +770,12 @@ describe("createProviderConfigStore", () => {
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "shared-openai-model", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "plan")).toEqual(selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "plan"), selection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "shared-openai-model",
planModeOpenAiModelInfo: modelInfoA,
@@ -320,14 +804,22 @@ describe("createProviderConfigStore", () => {
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
})
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
it("resolves a bare state modelId with fallback metadata and ignores a bare modelInfo", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
// The mode-specific model id alone identifies the selection; commits
// whose resolution was pure fallback intentionally leave the state
// modelInfo snapshot unset.
mocks.setApiConfiguration({ actModeOpenRouterModelId: "anthropic/claude-sonnet-4" })
expect(store.readSelection(providerId, "act")).toBeUndefined()
expect(store.readSelection(providerId, "act")).toMatchObject({
providerId,
modelId: "anthropic/claude-sonnet-4",
modelInfoSource: "fallback",
})
// A modelInfo snapshot without a model id is not a selection.
mocks.setApiConfiguration({ actModeOpenRouterModelInfo: modelInfoA })
expect(store.readSelection(providerId, "act")).toBeUndefined()
})
@@ -337,14 +829,14 @@ describe("createProviderConfigStore", () => {
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "provider/model-b", modelInfo: modelInfoB }
const planSelection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
const actSelection = selectionFromModelInfo(providerId, "provider/model-b", modelInfoB)
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
provider: "openrouter",
model: "provider/model-b",
@@ -361,7 +853,7 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -381,7 +873,7 @@ describe("createProviderConfigStore", () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("claude-code")
const selection = { providerId, modelId: "haiku", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "haiku", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -423,7 +915,7 @@ describe("createProviderConfigStore", () => {
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const events: ProviderConfigChange[] = []
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
store.subscribe((event) => events.push(event))
store.write(providerId, { apiKey: "openrouter-key" })
@@ -431,7 +923,12 @@ describe("createProviderConfigStore", () => {
expect(events.map((event) => event.kind)).toEqual(["fields", "selection"])
expect(events[0]).toMatchObject({ kind: "fields", providerId })
expect(events[1]).toEqual({ kind: "selection", providerId, mode: "act", selection })
expect(events[1]).toEqual({
kind: "selection",
providerId,
mode: "act",
selection: store.readSelection(providerId, "act"),
})
})
it("dispose unregisters listeners", async () => {
@@ -446,4 +943,50 @@ describe("createProviderConfigStore", () => {
expect(listener).not.toHaveBeenCalled()
})
// Contract test against the REAL SDK schemas (imported by relative path,
// bypassing the @cline/core mock above): the store's converters must pass
// every SDK capability through, and a fully-populated stored entry must
// parse under the schema `writeModelsFileSync` enforces in production.
it("round-trips every SDK model capability and a full override set under the real stored-entry schema", async () => {
const { ModelCapabilitySchema } = await import("@cline/shared")
// vi.importActual bypasses the @cline/core mock above and resolves via
// the vitest alias to the stub, which re-exports the real schema.
const { StoredModelEntrySchema } = (await vi.importActual("@cline/core")) as {
StoredModelEntrySchema: { parse(input: unknown): unknown }
}
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "contract-model",
overrides: {
name: "Contract Model",
maxTokens: 1024,
contextWindow: 200_000,
maxInputTokens: 100_000,
capabilities: [...ModelCapabilitySchema.options],
supportsVision: true,
supportsAttachments: true,
supportsReasoning: true,
inputPrice: 0.5,
outputPrice: 1.5,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.2,
temperature: 0.7,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: true,
},
})
const entry = mocks.getModelsFile().providers["openai-compatible"]?.models?.["contract-model"]
expect(entry).toBeDefined()
// No SDK capability may be silently stripped by the store's converter.
expect([...(entry?.capabilities as string[])].sort()).toEqual([...ModelCapabilitySchema.options].sort())
// The entry written by the extension must satisfy the real schema that
// the SDK's writeModelsFileSync enforces.
expect(() => StoredModelEntrySchema.parse(entry)).not.toThrow()
})
})
+431 -22
View File
@@ -1,5 +1,15 @@
import {
readModelsFileSync,
resolveModelsRegistryPath,
type StoredModelEntry,
syncStoredProviderRegistration,
writeModelsFileSync,
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { ModelCapabilitySchema } from "@cline/shared"
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
import { StateManager } from "@/core/storage/StateManager"
@@ -9,14 +19,17 @@ import type {
EffectiveProviderConfig,
Mode,
ModelSelection,
ModelSelectionOverrides,
ProviderConfigChange,
ProviderConfigChangeListener,
ProviderConfigPatch,
ProviderConfigStore,
ProviderId,
ResolvedModelSelection,
} from "./contracts"
import { buildEffectiveProviderConfig } from "./effective-config"
import { applyHostModelInfoOverrides } from "./host-overrides"
import { fromSdkApiFormat, nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-values"
import { toSdkProviderId } from "./sdk-provider-id"
import { adaptSdkModelInfo } from "./shape-adapter"
@@ -113,7 +126,7 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
// provider+mode so that switching between providers that share the same
// `*ModeApiModelId` key does not combine one provider's model id with
// another provider's model info.
const selectionMemory = new Map<string, ModelSelection>()
const selectionMemory = new Map<string, ResolvedModelSelection>()
function providerKey(providerId: ProviderId): string {
return providerId.toString()
@@ -168,11 +181,210 @@ function readProviderSettingsModelId(providerId: ProviderId): string | undefined
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
}
function fallbackModelInfo(modelId: string): ModelInfo {
return { ...openAiModelInfoSafeDefaults, name: modelId }
function sanitizeResolvedModelInfo(modelInfo: ModelInfo): ModelInfo {
const next = { ...modelInfo }
if (positiveFiniteNumber(next.maxTokens) === undefined) delete next.maxTokens
if (nonNegativeFiniteNumber(next.temperature) === undefined) delete next.temperature
return next
}
function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
function fallbackModelInfo(modelId: string): ModelInfo {
return sanitizeResolvedModelInfo({ ...openAiModelInfoSafeDefaults, name: modelId })
}
function toStoredCapabilities(capabilities: readonly string[] | undefined): StoredModelEntry["capabilities"] | undefined {
if (!capabilities) {
return undefined
}
// Validate against the SDK schema rather than a hardcoded list so new
// capabilities added to ModelCapabilitySchema are never silently stripped.
const next = new Set<NonNullable<StoredModelEntry["capabilities"]>[number]>()
for (const capability of capabilities) {
const parsed = ModelCapabilitySchema.safeParse(capability)
if (parsed.success) {
next.add(parsed.data)
}
}
return next.size > 0 ? [...next] : undefined
}
function toStoredApiFormat(apiFormat: ModelInfo["apiFormat"]): StoredModelEntry["apiFormat"] | undefined {
return toSdkApiFormat(apiFormat)
}
function fromStoredApiFormat(apiFormat: StoredModelEntry["apiFormat"]): ModelInfo["apiFormat"] | undefined {
return fromSdkApiFormat(apiFormat)
}
function readModelsState() {
return readModelsFileSync(resolveModelsRegistryPath(getProviderSettingsManager()))
}
/**
* Normalizes user-authored model metadata at the host/storage boundary.
* Token limits must be positive, prices and temperatures non-negative, and
* unsupported capabilities/formats are omitted. UI sentinels never cross
* this boundary; an object with no meaningful fields becomes undefined.
*/
function normalizeModelSelectionOverrides(overrides: ModelSelectionOverrides | undefined): ModelSelectionOverrides | undefined {
if (!overrides) {
return undefined
}
const maxTokens = positiveFiniteNumber(overrides.maxTokens)
const contextWindow = positiveFiniteNumber(overrides.contextWindow)
const maxInputTokens = positiveFiniteNumber(overrides.maxInputTokens)
const capabilities = toStoredCapabilities(overrides.capabilities)
const inputPrice = nonNegativeFiniteNumber(overrides.inputPrice)
const outputPrice = nonNegativeFiniteNumber(overrides.outputPrice)
const cacheReadsPrice = nonNegativeFiniteNumber(overrides.cacheReadsPrice)
const cacheWritesPrice = nonNegativeFiniteNumber(overrides.cacheWritesPrice)
const temperature = nonNegativeFiniteNumber(overrides.temperature)
const apiFormat = toStoredApiFormat(overrides.apiFormat) !== undefined ? overrides.apiFormat : undefined
const next: ModelSelectionOverrides = {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
...(capabilities !== undefined ? { capabilities } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(inputPrice !== undefined ? { inputPrice } : {}),
...(outputPrice !== undefined ? { outputPrice } : {}),
...(cacheReadsPrice !== undefined ? { cacheReadsPrice } : {}),
...(cacheWritesPrice !== undefined ? { cacheWritesPrice } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
return Object.keys(next).length > 0 ? next : undefined
}
function toStoredModelEntry(overrides: ModelSelectionOverrides): StoredModelEntry {
const capabilities = toStoredCapabilities(overrides.capabilities)
const apiFormat = toStoredApiFormat(overrides.apiFormat)
return {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
...(capabilities !== undefined ? { capabilities } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
}
function toSelectionOverrides(entry: StoredModelEntry | undefined): ModelSelectionOverrides | undefined {
if (!entry) {
return undefined
}
const apiFormat = fromStoredApiFormat(entry.apiFormat)
return normalizeModelSelectionOverrides({
...(entry.name !== undefined ? { name: entry.name } : {}),
...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}),
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
...(entry.maxInputTokens !== undefined ? { maxInputTokens: entry.maxInputTokens } : {}),
...(entry.capabilities !== undefined ? { capabilities: [...entry.capabilities] } : {}),
...(entry.supportsVision !== undefined ? { supportsVision: entry.supportsVision } : {}),
...(entry.supportsAttachments !== undefined ? { supportsAttachments: entry.supportsAttachments } : {}),
...(entry.supportsReasoning !== undefined ? { supportsReasoning: entry.supportsReasoning } : {}),
...(entry.inputPrice !== undefined ? { inputPrice: entry.inputPrice } : {}),
...(entry.outputPrice !== undefined ? { outputPrice: entry.outputPrice } : {}),
...(entry.cacheReadsPrice !== undefined ? { cacheReadsPrice: entry.cacheReadsPrice } : {}),
...(entry.cacheWritesPrice !== undefined ? { cacheWritesPrice: entry.cacheWritesPrice } : {}),
...(entry.temperature !== undefined ? { temperature: entry.temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(entry.isR1FormatRequired !== undefined ? { isR1FormatRequired: entry.isR1FormatRequired } : {}),
})
}
function readStoredModelEntry(providerId: ProviderId, modelId: string): { exists: boolean; entry: StoredModelEntry | undefined } {
const models = readModelsState().providers[providerSettingsProviderId(providerId)]?.models
return {
exists: models ? Object.hasOwn(models, modelId) : false,
entry: models?.[modelId],
}
}
function readModelOverrides(providerId: ProviderId, modelId: string): ModelSelectionOverrides | undefined {
return toSelectionOverrides(readStoredModelEntry(providerId, modelId).entry)
}
function writeModelOverrides(providerId: ProviderId, modelId: string, overrides: ModelSelectionOverrides | undefined): void {
const modelsPath = resolveModelsRegistryPath(getProviderSettingsManager())
const state = readModelsFileSync(modelsPath)
const provider = providerSettingsProviderId(providerId)
const providerEntry = state.providers[provider] ?? {}
const nextModels = { ...(providerEntry.models ?? {}) }
const normalizedOverrides = normalizeModelSelectionOverrides(overrides)
const storedEntry = normalizedOverrides ? toStoredModelEntry(normalizedOverrides) : undefined
if (storedEntry && Object.keys(storedEntry).length > 0) {
nextModels[modelId] = storedEntry
} else {
delete nextModels[modelId]
}
const nextProviderEntry = {
...providerEntry,
models: nextModels,
}
writeModelsFileSync(modelsPath, {
...state,
providers: {
...state.providers,
[provider]: nextProviderEntry,
},
})
// ensureCustomProvidersLoadedSync is load-once per path and would no-op
// here; sync the live registry explicitly so this write is visible to new
// sessions without a restart.
syncStoredProviderRegistration(provider, state.providers[provider], nextProviderEntry)
}
function applyModelOverrides(modelInfo: ModelInfo, overrides: ModelSelectionOverrides | undefined): ModelInfo {
if (!overrides) {
return modelInfo
}
const next: ModelInfo = { ...modelInfo }
if (overrides.name !== undefined) next.name = overrides.name
if (overrides.maxTokens !== undefined) next.maxTokens = overrides.maxTokens
if (overrides.contextWindow !== undefined) next.contextWindow = overrides.contextWindow
if (overrides.maxInputTokens !== undefined)
(next as ModelInfo & { maxInputTokens?: number }).maxInputTokens = overrides.maxInputTokens
if (overrides.inputPrice !== undefined) next.inputPrice = overrides.inputPrice
if (overrides.outputPrice !== undefined) next.outputPrice = overrides.outputPrice
if (overrides.cacheReadsPrice !== undefined) next.cacheReadsPrice = overrides.cacheReadsPrice
if (overrides.cacheWritesPrice !== undefined) next.cacheWritesPrice = overrides.cacheWritesPrice
if (overrides.temperature !== undefined) next.temperature = overrides.temperature
if (overrides.apiFormat !== undefined) next.apiFormat = overrides.apiFormat
// Capability arrays are additive fallback flags: they can only enable
// capabilities the base metadata lacks, never disable base capabilities
// (an array authored for one purpose, e.g. prompt-cache, must not strip
// unrelated base flags like vision). Explicit booleans win when both
// representations are present.
if (overrides.capabilities !== undefined) {
if (overrides.capabilities.includes("images")) next.supportsImages = true
if (overrides.capabilities.includes("prompt-cache")) next.supportsPromptCache = true
if (overrides.capabilities.includes("reasoning")) next.supportsReasoning = true
}
if (overrides.supportsVision !== undefined) next.supportsImages = overrides.supportsVision
if (overrides.supportsReasoning !== undefined) next.supportsReasoning = overrides.supportsReasoning
// apiFormat is canonical. The legacy R1 flag remains a compatibility alias
// that forces R1 only when explicitly true.
if (overrides.isR1FormatRequired) next.apiFormat = ApiFormat.R1_CHAT
return next
}
function readBaseModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
const sdkProviderId = toSdkProviderId(providerId)
const generatedModelInfo = getGeneratedModelsForProvider(sdkProviderId)[modelId]
if (isModelInfo(generatedModelInfo)) {
@@ -201,17 +413,36 @@ function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string):
return undefined
}
function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelection | undefined {
function resolveSelection(selection: ModelSelection, stateModelInfoHint?: ModelInfo): ResolvedModelSelection {
const overrides = normalizeModelSelectionOverrides(
selection.overrides ?? readModelOverrides(selection.providerId, selection.modelId),
)
// Base resolution order: SDK catalog, then the picker's persisted state
// snapshot (the only accurate data for dynamic-list models the static
// catalog does not know), then provider-safe fallback defaults.
const catalogModelInfo = readBaseModelInfoForProvider(selection.providerId, selection.modelId)
const baseModelInfo = catalogModelInfo ?? stateModelInfoHint ?? fallbackModelInfo(selection.modelId)
const modelInfoSource = catalogModelInfo ? "catalog" : stateModelInfoHint ? "state" : "fallback"
return {
...selection,
overrides,
modelInfoSource,
baseModelInfo,
modelInfo: sanitizeResolvedModelInfo(applyModelOverrides(baseModelInfo, overrides)),
}
}
export function resolveRuntimeModelSelection(providerId: ProviderId, modelId: string): ResolvedModelSelection {
return resolveSelection({ providerId, modelId })
}
function readSelectionFromProviderSettings(providerId: ProviderId): ResolvedModelSelection | undefined {
const modelId = readProviderSettingsModelId(providerId)
if (!modelId) {
return undefined
}
return {
providerId,
modelId,
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
}
return resolveSelection({ providerId, modelId })
}
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
@@ -281,6 +512,17 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
writeStateKey("clineApiKey", patch.auth?.accessToken)
writeStateKey("clineAccountId", patch.auth?.accountId)
}
// Mirror the Ollama context window to the legacy state key so older
// readers (proto ApiConfiguration, webview display fallback) stay in sync
// with providers.json.
if (provider === "ollama" && "contextWindow" in patch) {
const contextWindow = patch.contextWindow
writeStateKey(
"ollamaApiOptionsCtxNum",
typeof contextWindow === "number" && contextWindow > 0 ? String(contextWindow) : undefined,
)
}
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
@@ -330,6 +572,15 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if ("contextWindow" in patch) {
const contextWindow = patch.contextWindow
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
next.contextWindow = Math.floor(contextWindow)
} else {
delete next.contextWindow
}
}
if ("aws" in patch) {
const awsPatch = patch.aws
if (awsPatch === null || awsPatch === undefined) {
@@ -389,13 +640,27 @@ function syncedModes(mode: Mode): Mode[] {
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
}
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ResolvedModelSelection): void {
const updates: Partial<Record<SettingsKey, unknown>> = {}
for (const targetMode of syncedModes(mode)) {
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
const modelInfoKey = getModelInfoKey(providerId, targetMode)
if (modelInfoKey) {
updates[modelInfoKey] = selection.modelInfo
// For hint-eligible providers the snapshot must stay genuine base
// metadata: never persist fabricated fallback data (later reads
// would treat it as authoritative "state" data and shadow live
// catalog lookups), and persist the pre-override base rather than
// the resolved value (a deleted override must not be resurrected
// from a snapshot it was baked into). openai-compatible keeps the
// legacy resolved write — its snapshot is never used as a
// resolution base, and old extension versions still read it after
// a rollback.
if (usesStateModelInfoHint(providerId)) {
updates[modelInfoKey] =
selection.modelInfoSource === "fallback" ? undefined : (selection.baseModelInfo ?? selection.modelInfo)
} else {
updates[modelInfoKey] = selection.modelInfo
}
}
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
}
@@ -404,28 +669,164 @@ function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: Mo
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
// Prune model metadata that earlier builds may have written to providers.json.
delete next.contextWindow
// Prune model metadata that earlier builds may have written to
// providers.json — except for Ollama, whose contextWindow is a real
// user setting (maps to num_ctx) written by the settings UI.
if (providerKey(providerId) !== "ollama") {
delete next.contextWindow
}
delete next.maxTokens
saveProviderSettings(providerId, next)
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
type LegacyModelInfo = ModelInfo & { maxInputTokens?: number; isR1FormatRequired?: boolean }
type MutableModelSelectionOverrides = { -readonly [Key in keyof ModelSelectionOverrides]: ModelSelectionOverrides[Key] }
function legacyModelInfoToOverrides(modelInfo: LegacyModelInfo, fallback: ModelInfo): ModelSelectionOverrides | undefined {
const fallbackInfo = fallback as LegacyModelInfo
const overrides: MutableModelSelectionOverrides = {}
if (modelInfo.name !== undefined && modelInfo.name !== fallback.name) overrides.name = modelInfo.name
if (modelInfo.maxTokens !== undefined && modelInfo.maxTokens !== fallback.maxTokens) overrides.maxTokens = modelInfo.maxTokens
if (modelInfo.contextWindow !== undefined && modelInfo.contextWindow !== fallback.contextWindow)
overrides.contextWindow = modelInfo.contextWindow
if (modelInfo.maxInputTokens !== undefined && modelInfo.maxInputTokens !== fallbackInfo.maxInputTokens)
overrides.maxInputTokens = modelInfo.maxInputTokens
const supportsVision = modelInfo.supportsImages ?? fallback.supportsImages
if (Boolean(supportsVision) !== Boolean(fallback.supportsImages)) overrides.supportsVision = Boolean(supportsVision)
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
const capabilities: string[] = []
if (supportsVision) capabilities.push("images")
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
overrides.capabilities = capabilities
}
if (modelInfo.inputPrice !== undefined && modelInfo.inputPrice !== fallback.inputPrice)
overrides.inputPrice = modelInfo.inputPrice
if (modelInfo.outputPrice !== undefined && modelInfo.outputPrice !== fallback.outputPrice)
overrides.outputPrice = modelInfo.outputPrice
if (modelInfo.cacheReadsPrice !== undefined && modelInfo.cacheReadsPrice !== fallback.cacheReadsPrice)
overrides.cacheReadsPrice = modelInfo.cacheReadsPrice
if (modelInfo.cacheWritesPrice !== undefined && modelInfo.cacheWritesPrice !== fallback.cacheWritesPrice)
overrides.cacheWritesPrice = modelInfo.cacheWritesPrice
if (modelInfo.temperature !== undefined && modelInfo.temperature !== fallback.temperature)
overrides.temperature = modelInfo.temperature
if (modelInfo.apiFormat !== undefined && modelInfo.apiFormat !== fallback.apiFormat) overrides.apiFormat = modelInfo.apiFormat
if (modelInfo.isR1FormatRequired === true && fallbackInfo.isR1FormatRequired !== true) overrides.isR1FormatRequired = true
return normalizeModelSelectionOverrides(overrides)
}
// Providers/models whose legacy-state migration has already been attempted in
// this process. The migration runs from the read path, so it must be cheap on
// repeat reads and must never run more than once per selection — including
// when the legacy snapshot diffs to an empty override set and nothing is
// written.
const attemptedLegacyMigrations = new Set<string>()
function migrateLegacyModelOverridesIfNeeded(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): void {
if (providerSettingsProviderId(providerId) !== "openai-compatible") {
return
}
const migrationKey = `${providerId}:${modelId}`
if (attemptedLegacyMigrations.has(migrationKey)) {
return
}
attemptedLegacyMigrations.add(migrationKey)
if (readStoredModelEntry(providerId, modelId).exists) {
return
}
if (readBaseModelInfoForProvider(providerId, modelId) !== undefined) {
return
}
const overrides = legacyModelInfoToOverrides(modelInfo as LegacyModelInfo, fallbackModelInfo(modelId))
if (overrides) {
try {
writeModelOverrides(providerId, modelId, overrides)
} catch (error) {
// The migration is best-effort and runs inside read paths; a
// failed write (read-only fs, disk full) must not fail read RPCs.
Logger.warn(
`[ModelCatalog] Failed to migrate legacy overrides for provider=${providerId} model=${modelId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
}
/**
* The picker writes the live model metadata to the mode-specific
* `*ModeModelInfo` state key before committing. When the state still refers to
* the model being resolved, that snapshot is the best available base for
* dynamic-list models the static catalog does not know.
*
* openai-compatible is excluded: its legacy state snapshot is user-authored
* metadata that {@link migrateLegacyModelOverridesIfNeeded} converts into
* models.json overrides, which are the source of truth there. Feeding the
* snapshot back as a base would resurrect overrides the user deleted.
*/
function usesStateModelInfoHint(providerId: ProviderId): boolean {
return providerSettingsProviderId(providerId) !== "openai-compatible"
}
/**
* Pickers write `{ ...openAiModelInfoSafeDefaults, name: modelId }` to the
* state key when the user selects an id the live model list does not (yet)
* contain. Such a snapshot carries no real information and must not be
* treated as authoritative "state" metadata.
*/
function isSafeDefaultsSnapshot(modelInfo: ModelInfo, modelId: string): boolean {
const fabricated: Record<string, unknown> = { ...openAiModelInfoSafeDefaults, name: modelId }
const snapshot = modelInfo as unknown as Record<string, unknown>
for (const key of new Set([...Object.keys(fabricated), ...Object.keys(snapshot)])) {
if (fabricated[key] !== snapshot[key]) {
return false
}
}
return true
}
function readStateModelInfoHint(providerId: ProviderId, mode: Mode, modelId: string): ModelInfo | undefined {
if (!usesStateModelInfoHint(providerId)) {
return undefined
}
const modelInfoKey = getModelInfoKey(providerId, mode)
if (!modelInfoKey) {
return undefined
}
const apiConfiguration = StateManager.get().getApiConfiguration()
const stateModelId = apiConfiguration[getModelIdKey(providerId, mode)]
const stateModelInfo = apiConfiguration[modelInfoKey]
return stateModelId === modelId && isModelInfo(stateModelInfo) && !isSafeDefaultsSnapshot(stateModelInfo, modelId)
? stateModelInfo
: undefined
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
const apiConfiguration = StateManager.get().getApiConfiguration()
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
const modelInfoKey = getModelInfoKey(providerId, mode)
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
if (modelInfoKey) {
const modelInfo = apiConfiguration[modelInfoKey]
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
return providerSettingsSelection
if (typeof modelId !== "string" || modelId.length === 0) {
return readSelectionFromProviderSettings(providerId)
}
return { providerId, modelId, modelInfo }
// The mode-specific model id alone identifies the selection; the state
// modelInfo snapshot is optional input for legacy migration and, for
// dynamic-list providers, the base-metadata hint. Fallback-tier commits
// intentionally leave it unset.
if (isModelInfo(modelInfo)) {
migrateLegacyModelOverridesIfNeeded(providerId, modelId, modelInfo)
}
return resolveSelection({ providerId, modelId }, readStateModelInfoHint(providerId, mode, modelId))
}
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
const provider = providerForStorage(providerId)
if (activeProvider !== provider) {
@@ -464,7 +865,7 @@ export function createProviderConfigStore(): ProviderConfigStore {
return { ...buildEffectiveProviderConfig(providerId) }
},
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
return readSelectionFromState(providerId, mode)
},
@@ -482,9 +883,17 @@ export function createProviderConfigStore(): ProviderConfigStore {
},
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
writeSelectionToState(providerId, mode, selection)
writeSelectionToProviderSettings(providerId, selection)
emit({ kind: "selection", providerId, mode, selection })
if (selection.overrides !== undefined) {
writeModelOverrides(providerId, selection.modelId, selection.overrides)
}
// Read the picker-written state snapshot before writeSelectionToState
// replaces it, so dynamic-list models keep their live metadata instead
// of being re-resolved to fallback defaults.
const stateModelInfoHint = readStateModelInfoHint(providerId, mode, selection.modelId)
const resolvedSelection = resolveSelection({ providerId, modelId: selection.modelId }, stateModelInfoHint)
writeSelectionToState(providerId, mode, resolvedSelection)
emit({ kind: "selection", providerId, mode, selection: resolvedSelection })
},
}
}
@@ -0,0 +1,36 @@
import { readModelsFileSync, writeModelsFileSync } from "@cline/core"
import { describe, expect, it } from "vitest"
type StoredModelsFile = ReturnType<typeof readModelsFileSync>
const firstPath = "/tmp/first-models.json"
const secondPath = "/tmp/second-models.json"
const storedModelsFile = (): StoredModelsFile => ({
version: 1,
providers: {
"openai-compatible": {
models: {
custom: { name: "Custom", capabilities: ["tools"] },
},
},
},
})
describe("cline core model-file test stub", () => {
it("isolates paths and returns defensive copies", () => {
const input = storedModelsFile()
writeModelsFileSync(firstPath, input)
input.providers["openai-compatible"].models!.custom.name = "mutated input"
const firstRead = readModelsFileSync(firstPath)
firstRead.providers["openai-compatible"].models!.custom.name = "mutated read"
expect(readModelsFileSync(firstPath)).toEqual(storedModelsFile())
expect(readModelsFileSync(secondPath)).toEqual({ version: 1, providers: {} })
})
it("cannot observe model writes from the preceding test", () => {
expect(readModelsFileSync(firstPath)).toEqual({ version: 1, providers: {} })
})
})
@@ -454,13 +454,35 @@ describe("SdkDiffEditCoordinator", () => {
expect(callOrder).toEqual(["close", "apply"])
})
it("applies patches without preview sessions directly", async () => {
it("shows a brief preview around auto-approved patches", async () => {
await writeFile("patched.ts", "line one\nline two\n")
const patch = ["*** Begin Patch", "*** Update File: patched.ts", "@@", "-line one", "+line ONE", "*** End Patch"].join(
"\n",
)
const result = await coordinator.executeApplyPatchTool({ input: patch }, tempDir, makeContext("tc9"))
expect(result).toBe("fallback apply_patch result")
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
expect(previews).toHaveLength(1)
expect(previews[0].opened).toMatchObject({
absolutePath: path.join(tempDir, "patched.ts"),
leftContent: "line one\nline two\n",
rightContent: "line ONE\nline two\n",
})
expect(previews[0].closed).toBe(1)
})
it("applies auto-approved patches without a preview when background edit is enabled", async () => {
backgroundEdit = true
const result = await coordinator.executeApplyPatchTool(
{ input: "*** Begin Patch\n*** End Patch" },
tempDir,
makeContext("tc9"),
)
expect(result).toBe("fallback apply_patch result")
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
expect(previews).toHaveLength(0)
})
})
@@ -122,12 +122,32 @@ export class SdkDiffEditCoordinator {
}
/**
* The `apply_patch` tool executor override: close the preview, then delegate the
* whole patch application to the SDK's default executor.
* The `apply_patch` tool executor override: manually-approved patches close their
* approval preview before applying; auto-approved patches show a brief preview
* around execution, matching the `editor` tool behavior.
*/
async executeApplyPatchTool(input: ApplyPatchInput, cwd: string, context: AgentToolContext): Promise<string> {
await this.discardPreview(context.toolCallId ?? "")
return this.fallbackApplyPatchExecutor(input, cwd, context)
const toolCallId = context.toolCallId ?? ""
const hadPreApprovalPreview = this.sessions.has(toolCallId)
try {
if (hadPreApprovalPreview) {
await this.discardPreview(toolCallId)
} else if (!this.options.isBackgroundEditEnabled()) {
try {
await this.openPatchPreview(toolCallId, input)
} catch (error) {
Logger.warn(`[SdkDiffEditCoordinator] Failed to show auto-approve patch preview: ${error}`)
}
}
const result = await this.fallbackApplyPatchExecutor(input, cwd, context)
if (!hadPreApprovalPreview && this.sessions.get(toolCallId)?.preview) {
await lingerDelay(this.autoApprovePreviewLingerMs, context.signal)
}
return result
} finally {
await this.discardPreview(toolCallId)
}
}
/** Closes one preview (reject / abort / edit applied). Never throws; unknown ids are a no-op. */
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest"
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
describe("SdkForegroundCommandCoordinator", () => {
it("reports isRunning while a handle is registered and notifies on changes", () => {
const onRunningChanged = vi.fn()
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
expect(coordinator.isRunning).toBe(false)
const unregister = coordinator.register({ detach: () => {} })
expect(coordinator.isRunning).toBe(true)
expect(onRunningChanged).toHaveBeenCalledWith(true)
unregister()
expect(coordinator.isRunning).toBe(false)
expect(onRunningChanged).toHaveBeenCalledWith(false)
})
it("only notifies on actual transitions, not per handle", () => {
const onRunningChanged = vi.fn()
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
const unregister1 = coordinator.register({ detach: () => {} })
const unregister2 = coordinator.register({ detach: () => {} })
expect(onRunningChanged).toHaveBeenCalledTimes(1)
unregister1()
expect(onRunningChanged).toHaveBeenCalledTimes(1)
unregister2()
expect(onRunningChanged).toHaveBeenCalledTimes(2)
})
it("unregister is idempotent", () => {
const onRunningChanged = vi.fn()
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
const unregister = coordinator.register({ detach: () => {} })
unregister()
unregister()
expect(onRunningChanged).toHaveBeenCalledTimes(2)
})
it("proceedWhileRunning detaches every registered handle and reports the count", () => {
const coordinator = new SdkForegroundCommandCoordinator()
const detach1 = vi.fn()
const detach2 = vi.fn()
coordinator.register({ detach: detach1 })
coordinator.register({ detach: detach2 })
expect(coordinator.proceedWhileRunning()).toBe(2)
expect(detach1).toHaveBeenCalledTimes(1)
expect(detach2).toHaveBeenCalledTimes(1)
})
it("proceedWhileRunning is a no-op returning 0 when nothing is running", () => {
const coordinator = new SdkForegroundCommandCoordinator()
expect(coordinator.proceedWhileRunning()).toBe(0)
})
it("proceedWhileRunning survives a handle whose detach throws", () => {
const coordinator = new SdkForegroundCommandCoordinator()
const detach2 = vi.fn()
coordinator.register({
detach: () => {
throw new Error("boom")
},
})
coordinator.register({ detach: detach2 })
expect(coordinator.proceedWhileRunning()).toBe(2)
expect(detach2).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,80 @@
/**
* Tracks in-flight foreground (VS Code terminal) command executions so the
* "Proceed While Running" button can detach them: each pending tool call
* returns with its partial output while the command keeps running in the
* user's terminal, streaming further output to a log file.
*
* Owned by SdkController so it outlives session rebuilds (which recreate the
* tool set and its reused executor closure). Handles are registered per tool
* invocation never on the reused executor so parallel commands in one
* tool call each get their own handle and log file.
*/
import { Logger } from "@/shared/services/Logger"
export interface ForegroundCommandHandle {
/**
* Stop waiting for the command: flush the output captured so far to a
* log file, keep appending until the command completes, and resolve the
* pending tool execution with the partial output. Idempotent.
*/
detach(): void
}
export interface SdkForegroundCommandCoordinatorOptions {
/** Called whenever isRunning flips; used to push the flag to the webview. */
onRunningChanged?: (running: boolean) => void
}
export class SdkForegroundCommandCoordinator {
private readonly handles = new Set<ForegroundCommandHandle>()
constructor(private readonly options: SdkForegroundCommandCoordinatorOptions = {}) {}
/** Whether any foreground command is currently awaited by a tool call. */
get isRunning(): boolean {
return this.handles.size > 0
}
/**
* Track one in-flight foreground execution. Returns an unregister
* function the caller must invoke when the execution settles (completes,
* fails, aborts, or detaches) typically from a `finally` block.
*/
register(handle: ForegroundCommandHandle): () => void {
const wasRunning = this.isRunning
this.handles.add(handle)
this.notifyIfChanged(wasRunning)
return () => {
const wasRunningBefore = this.isRunning
if (this.handles.delete(handle)) {
this.notifyIfChanged(wasRunningBefore)
}
}
}
/**
* Detach every in-flight foreground command ("Proceed While Running").
* Each pending tool execution resolves with its partial output and log
* file path; the commands keep running in their terminals.
*
* @returns the number of commands detached (0 when none were running).
*/
proceedWhileRunning(): number {
const handles = [...this.handles]
for (const handle of handles) {
try {
handle.detach()
} catch (error) {
Logger.error("[ForegroundCommands] Failed to detach foreground command:", error)
}
}
return handles.length
}
private notifyIfChanged(wasRunning: boolean): void {
if (this.isRunning !== wasRunning) {
this.options.onRunningChanged?.(this.isRunning)
}
}
}
@@ -12,6 +12,7 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
import { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import type { ActiveSession } from "./cline-session-factory"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { buildToolPolicies } from "./sdk-tool-policies"
import type { SdkSessionHost } from "./session-host"
import { VscodeSessionHost } from "./vscode-session-host"
@@ -32,6 +33,8 @@ export interface SdkSessionLifecycleOptions {
onSessionEvent: (event: CoreSessionEvent) => void
/** Lazy factory for the VscodeTerminalManager (foreground terminal support). */
getTerminalManager?: () => VscodeTerminalManager
/** Registry of in-flight foreground executions for "Proceed While Running". */
foregroundCommands?: SdkForegroundCommandCoordinator
/** Returns the latest prepared remote-config integration, if remote config is active. */
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
/** Shared SDK telemetry service owned by SdkController. */
@@ -322,6 +325,7 @@ export class SdkSessionLifecycle {
editorExecutor: this.options.editorExecutor,
applyPatchExecutor: this.options.applyPatchExecutor,
getTerminalManager: this.options.getTerminalManager,
foregroundCommands: this.options.foregroundCommands,
getRemoteConfigIntegration: this.options.getRemoteConfigIntegration,
telemetry: this.options.telemetry,
})
@@ -1,9 +1,21 @@
import { CommandExitError } from "@cline/core"
import { EventEmitter } from "events"
import { describe, expect, it } from "vitest"
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
import * as fs from "fs"
import { describe, expect, it, vi } from "vitest"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { executeForeground, formatCommandForTerminal } from "./vscode-run-commands-tool"
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { executeForeground, formatCommandForTerminal, PROCEED_LOG_MAX_BYTES } from "./vscode-run-commands-tool"
// The real telemetry proxy lazily initializes TelemetryService, which requires
// a HostProvider that unit tests don't set up.
vi.mock("@services/telemetry", () => ({
TerminalUserInterventionAction: { PROCESS_WHILE_RUNNING: "process_while_running" },
telemetryService: {
captureTerminalUserIntervention: () => {},
captureTerminalExecution: () => {},
},
}))
/**
* Minimal fake of the process object returned by VscodeTerminalManager.runCommand():
@@ -12,11 +24,13 @@ import { executeForeground, formatCommandForTerminal } from "./vscode-run-comman
*/
function createFakeTerminalProcess(options: { lines?: string[]; completionDetails?: TerminalCompletionDetails } = {}) {
const emitter = new EventEmitter()
let resolvePromise!: () => void
// Emit on a macrotask (not a microtask) so executeForeground's
// `await terminalManager.getOrCreateTerminal(cwd)` and subsequent
// `process.on("line", ...)` registration are guaranteed to run first,
// matching the ordering a real terminal process provides.
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
setTimeout(() => {
for (const line of options.lines ?? []) {
emitter.emit("line", line)
@@ -31,6 +45,10 @@ function createFakeTerminalProcess(options: { lines?: string[]; completionDetail
catch: promise.catch.bind(promise),
finally: promise.finally.bind(promise),
getCompletionDetails: () => options.completionDetails ?? {},
detach: () => {
emitter.emit("continue")
resolvePromise()
},
})
return fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>
}
@@ -42,6 +60,50 @@ function createFakeTerminalManager(process: ReturnType<VscodeTerminalManager["ru
} as unknown as VscodeTerminalManager
}
/**
* A controllable fake terminal process for detach tests: the caller decides
* when lines are emitted and when the command completes. Mirrors the real
* VscodeTerminalProcess contract: detach() resolves the awaited promise while
* 'line'/'completed' events keep flowing.
*/
function createControllableTerminalProcess() {
const emitter = new EventEmitter()
let resolvePromise!: () => void
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
})
const fakeProcess = Object.assign(emitter, {
then: promise.then.bind(promise),
catch: promise.catch.bind(promise),
finally: promise.finally.bind(promise),
getCompletionDetails: () => ({}),
detach: () => {
emitter.emit("continue")
resolvePromise()
},
})
return {
process: fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>,
emitLine: (line: string) => emitter.emit("line", line),
complete: (details?: TerminalCompletionDetails) => {
emitter.emit("completed", details)
emitter.emit("continue")
resolvePromise()
},
}
}
/** Poll until the predicate holds, for asserting on async log-file writes. */
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("waitFor timed out")
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
describe("formatCommandForTerminal", () => {
it.each([
{
@@ -172,4 +234,207 @@ describe("executeForeground", () => {
expect((error as InstanceType<typeof CommandExitError>).output).toContain("Terminal closed")
}
})
it("unregisters its foreground handle when the command completes normally", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const terminalManager = createFakeTerminalManager(createFakeTerminalProcess({ lines: ["hello"] }))
const result = await executeForeground("echo hello", "/workspace", terminalManager, 1000, undefined, coordinator)
expect(result).toBe("hello")
expect(coordinator.isRunning).toBe(false)
})
})
describe("executeForeground — Proceed While Running", () => {
it("detach returns the partial output with the log file path, and later output lands in the log", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
emitLine("listening on :3000")
expect(coordinator.proceedWhileRunning()).toBe(1)
const result = await resultPromise
expect(result).toContain("still running")
expect(result).toContain("listening on :3000")
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
expect(logFilePath).toBeTruthy()
// The handle is unregistered once the tool call returns.
expect(coordinator.isRunning).toBe(false)
// Output emitted after detach is appended to the log file, and
// completion closes it out with a completion marker.
emitLine("compiled successfully")
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain("listening on :3000") // buffered lines flushed at detach
expect(log).toContain("compiled successfully") // streamed after detach
fs.rmSync(logFilePath!, { force: true })
})
it("detaches each parallel command into its own log file", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const first = createControllableTerminalProcess()
const second = createControllableTerminalProcess()
const firstPromise = executeForeground(
"first-cmd",
"/workspace",
createFakeTerminalManager(first.process),
100_000,
undefined,
coordinator,
)
const secondPromise = executeForeground(
"second-cmd",
"/workspace",
createFakeTerminalManager(second.process),
100_000,
undefined,
coordinator,
)
await waitFor(() => coordinator.isRunning)
first.emitLine("first output")
second.emitLine("second output")
expect(coordinator.proceedWhileRunning()).toBe(2)
const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise])
const firstLog = /redirected to this file[^:]*: (.+)$/m.exec(firstResult)?.[1]?.trim()
const secondLog = /redirected to this file[^:]*: (.+)$/m.exec(secondResult)?.[1]?.trim()
expect(firstLog).toBeTruthy()
expect(secondLog).toBeTruthy()
expect(firstLog).not.toBe(secondLog)
expect(coordinator.isRunning).toBe(false)
first.complete()
second.complete()
await waitFor(() => {
try {
return (
fs.readFileSync(firstLog!, "utf8").includes("[Command completed]") &&
fs.readFileSync(secondLog!, "utf8").includes("[Command completed]")
)
} catch {
return false
}
})
expect(fs.readFileSync(firstLog!, "utf8")).toContain("first output")
expect(fs.readFileSync(secondLog!, "utf8")).toContain("second output")
fs.rmSync(firstLog!, { force: true })
fs.rmSync(secondLog!, { force: true })
})
it("stops logging before a line that would exceed the size cap", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
expect(coordinator.proceedWhileRunning()).toBe(1)
const result = await resultPromise
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
expect(logFilePath).toBeTruthy()
// A single line larger than the whole cap must not be written at all —
// the cap is checked before writing, so one huge line (e.g. a dumped
// blob) cannot blow the log far past PROCEED_LOG_MAX_BYTES.
emitLine("small line before the blob")
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
emitLine("after the cap")
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain("small line before the blob")
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
expect(log).not.toContain("xxxx")
expect(log).not.toContain("after the cap")
expect(log.length).toBeLessThan(PROCEED_LOG_MAX_BYTES)
fs.rmSync(logFilePath!, { force: true })
})
it("applies the size cap to lines buffered before detach", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
expect(coordinator.proceedWhileRunning()).toBe(1)
const result = await resultPromise
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
expect(logFilePath).toBeTruthy()
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
expect(log).not.toContain("xxxx")
expect(Buffer.byteLength(log)).toBeLessThanOrEqual(PROCEED_LOG_MAX_BYTES)
fs.rmSync(logFilePath!, { force: true })
})
it("freezes the partial output at detach while later output still reaches the log", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
emitLine("before detach")
expect(coordinator.proceedWhileRunning()).toBe(1)
// Emitted after detach but before the tool call's result is built:
// must appear only in the log, never in the partial output.
emitLine("after detach")
const result = await resultPromise
expect(result).toContain("before detach")
expect(result).not.toContain("after detach")
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain("before detach")
expect(log).toContain("after detach")
fs.rmSync(logFilePath!, { force: true })
})
})
+131 -6
View File
@@ -21,12 +21,16 @@ import {
truncateCommandOutput,
} from "@cline/core"
import type { AgentTool } from "@cline/shared"
import { telemetryService } from "@services/telemetry"
import { TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { ClineTempManager } from "@services/temp"
import * as fs from "fs"
import { StateManager } from "@/core/storage/StateManager"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { MAX_UNRETRIEVED_LINES } from "@/integrations/terminal/constants"
import type { ITerminalProcess } from "@/integrations/terminal/types"
import { Logger } from "@/shared/services/Logger"
import { getShellForProfile } from "@/utils/shell"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
// ---------------------------------------------------------------------------
// Types
@@ -38,6 +42,14 @@ type VscodeTerminalExecutionMode = "vscodeTerminal" | "backgroundExec"
/** Foreground VS Code terminals cannot be forcibly terminated; give long-running commands room to finish. */
export const VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS = 60 * 60 * 1000
/**
* Cap on the "Proceed While Running" log file. A detached devserver can log
* for days; once the cap is hit we stop appending and note the truncation.
* ClineTempManager's periodic cleanup (age + total-size caps) is the backstop
* for the files themselves.
*/
export const PROCEED_LOG_MAX_BYTES = 10 * 1024 * 1024
/** Options for creating the VSCode run_commands tool. */
export interface VscodeRunCommandsToolOptions {
/** Workspace root directory. */
@@ -48,6 +60,14 @@ export interface VscodeRunCommandsToolOptions {
bashTimeoutMs?: number
/** Terminal execution mode captured when this session's tool set is built. */
vscodeTerminalExecutionMode?: VscodeTerminalExecutionMode
/**
* Registry of in-flight foreground executions, owned by SdkController.
* When provided, each foreground command can be detached via the
* "Proceed While Running" button. Foreground-only: background (SDK
* child_process) executions cannot be detached their abort signal
* kills the process tree.
*/
foregroundCommands?: SdkForegroundCommandCoordinator
}
// ---------------------------------------------------------------------------
@@ -74,6 +94,69 @@ export function formatCommandForTerminal(command: ShellCommand): string {
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
}
/**
* Stream the rest of a detached command's output to a log file: write the
* lines buffered so far, then append each further 'line' event until
* 'completed'. The write volume is capped at PROCEED_LOG_MAX_BYTES; the
* stream is always closed by the 'completed' event, which the terminal
* process emits on every exit path (command end, Ctrl+C, terminal closed,
* markerless fallback).
*/
function beginLogCapture(process: ITerminalProcess, terminalCommand: string, existingLines: string[]): string {
const logFilePath = ClineTempManager.createTempFilePath("proceed-while-running")
const stream = fs.createWriteStream(logFilePath, { flags: "a" })
const sizeCapMessage = `[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached; further output is not logged.]`
stream.on("error", (error) => {
Logger.error(`[VscodeRunCommands] Failed writing proceed-while-running log ${logFilePath}:`, error)
})
let bytesWritten = 0
const tryWriteLine = (line: string): boolean => {
const chunk = `${line}\n`
const chunkBytes = Buffer.byteLength(chunk)
if (bytesWritten + chunkBytes > PROCEED_LOG_MAX_BYTES) {
return false
}
bytesWritten += chunkBytes
stream.write(chunk)
return true
}
let sizeCapReached = !tryWriteLine(`[Running command: ${terminalCommand}]`)
for (const line of existingLines) {
if (!tryWriteLine(line)) {
sizeCapReached = true
break
}
}
const onLine = (line: string): void => {
// Check the cap before writing: a single huge line (e.g. a dumped
// binary blob or minified bundle) must not blow past the cap.
if (!tryWriteLine(line)) {
tryWriteLine(sizeCapMessage)
process.removeListener("line", onLine)
}
}
if (sizeCapReached) {
tryWriteLine(sizeCapMessage)
} else {
process.on("line", onLine)
}
process.once("completed", (details) => {
process.removeListener("line", onLine)
const exitCode = details?.exitCode
tryWriteLine(
exitCode !== undefined && exitCode !== null
? `[Command completed with exit code ${exitCode}]`
: "[Command completed]",
)
stream.end()
})
return logFilePath
}
/** Exported for direct unit testing of the CommandExitError/terminalClosed mapping. */
export async function executeForeground(
command: ShellCommand,
@@ -81,6 +164,7 @@ export async function executeForeground(
terminalManager: VscodeTerminalManager,
maxOutputChars: number,
abortSignal?: AbortSignal,
foregroundCommands?: SdkForegroundCommandCoordinator,
): Promise<string> {
const terminalCommand = formatCommandForTerminal(command)
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd)
@@ -100,7 +184,7 @@ export async function executeForeground(
// truncateCommandOutput's own head/tail strategy below — since build/test
// failures usually appear at the end of output.
const maxBufferedLines = MAX_UNRETRIEVED_LINES
process.on("line", (line: string) => {
const bufferLine = (line: string): void => {
if (outputLines.length < maxBufferedLines) {
outputLines.push(line)
} else {
@@ -108,7 +192,8 @@ export async function executeForeground(
outputLines.push(line)
droppedLines++
}
})
}
process.on("line", bufferLine)
// Handle abort signal
if (abortSignal) {
@@ -121,8 +206,33 @@ export async function executeForeground(
process.once("continue", cleanupAbortListener)
}
// Wait for completion
await process
// "Proceed While Running": register a per-invocation handle so the user
// can detach this command. Detaching redirects the remaining output to a
// log file and resolves the awaited promise; the command keeps running in
// the user's terminal (and the terminal stays busy until it completes).
let detachedLogFilePath: string | undefined
const unregister = foregroundCommands?.register({
detach: () => {
if (detachedLogFilePath !== undefined) {
return
}
detachedLogFilePath = beginLogCapture(process, terminalCommand, outputLines)
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING, "vscode")
// detach() flushes any partial line (reaching both bufferLine and
// the log) before resolving the awaited promise. After that the
// partial output is final: stop buffering so the remaining
// (log-only) output doesn't mutate outputLines while it's read.
process.detach()
process.removeListener("line", bufferLine)
},
})
try {
// Wait for completion (or detach, which also resolves the promise)
await process
} finally {
unregister?.()
}
if (abortSignal?.aborted) {
throw new Error("Command execution aborted")
}
@@ -135,6 +245,14 @@ export async function executeForeground(
maxChars: maxOutputChars,
})
if (detachedLogFilePath !== undefined) {
return [
"The user chose to proceed while the command is still running in their terminal.",
`This is partial output; further output is being redirected to this file, which you can read to check progress: ${detachedLogFilePath}`,
output.length > 0 ? `Output so far:\n${output}` : "No output so far.",
].join("\n")
}
const completionDetails = process.getCompletionDetails?.()
// A terminal closed mid-command has no exit code and no reliable output —
@@ -240,6 +358,13 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
if (!terminalManager) {
terminalManager = getTerminalManager()
}
return await executeForeground(command, commandCwd || cwd, terminalManager, MAX_COMMAND_OUTPUT_CHARS, context.signal)
return await executeForeground(
command,
commandCwd || cwd,
terminalManager,
MAX_COMMAND_OUTPUT_CHARS,
context.signal,
options.foregroundCommands,
)
}
}
@@ -3,6 +3,7 @@ import { type AgentTool, type AgentToolContext, createTool } from "@cline/shared
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { createVscodeRunCommandsTool, VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS } from "./vscode-run-commands-tool"
interface McpToolDescriptor {
@@ -124,6 +125,8 @@ export interface VscodeExtraToolsOptions {
getTerminalManager?: () => VscodeTerminalManager
/** Current VS Code terminal execution mode, captured when the session tools are built. */
vscodeTerminalExecutionMode?: "vscodeTerminal" | "backgroundExec"
/** Registry of in-flight foreground executions for "Proceed While Running". */
foregroundCommands?: SdkForegroundCommandCoordinator
}
export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExtraToolsOptions): Promise<AgentTool[]> {
@@ -159,6 +162,7 @@ export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExt
getTerminalManager: options.getTerminalManager,
bashTimeoutMs: executionMode === "vscodeTerminal" ? VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS : undefined,
vscodeTerminalExecutionMode: executionMode,
foregroundCommands: options.foregroundCommands,
}),
)
Logger.log(
+5 -1
View File
@@ -36,9 +36,10 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
import { getDistinctId } from "@/services/logging/distinctId"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import type { SdkSessionHost } from "./session-host"
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
import { createVscodeExtraTools } from "./vscode-runtime-builder"
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
export interface VscodeSessionHostOptions {
mcpHub: McpHub
@@ -75,6 +76,8 @@ export interface VscodeSessionHostOptions {
* with a custom tool that supports foreground/background terminal execution.
*/
getTerminalManager?: () => VscodeTerminalManager
/** Registry of in-flight foreground executions for "Proceed While Running". */
foregroundCommands?: SdkForegroundCommandCoordinator
}
export class VscodeSessionHost implements SdkSessionHost {
@@ -133,6 +136,7 @@ export class VscodeSessionHost implements SdkSessionHost {
cwd: inputWithRemoteConfig.config.cwd,
getTerminalManager: options.getTerminalManager,
vscodeTerminalExecutionMode: getEffectiveTerminalExecutionMode(requestedTerminalExecutionMode),
foregroundCommands: options.foregroundCommands,
})
return {
...inputWithRemoteConfig,
@@ -124,6 +124,7 @@ export class WebviewGrpcBridge {
stateManager,
mcpHub: undefined,
backgroundCommandRunning: false,
foregroundCommandRunning: false,
backgroundCommandTaskId: undefined,
})
await sendStateUpdate(state)

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