Compare commits

..

97 Commits

Author SHA1 Message Date
Dominic Cooney f83420464d Prototype: stamp an environment_notice when the terminal shell changes.
Mirrors the mode_notice mechanism: a tracker records the shell change
(round trips cancel; same-shell profile hops record nothing) and
fireAndForgetSend prepends the notice to the next outbound message.

Known-defective: user input paths that resolve pending ask_question or
tool approvals bypass fireAndForgetSend, and delivery through queued or
steered sends is unverified; in manual testing the model did not receive
the notice. See shell-change-notice-problems.md (on the author's desktop)
for the analysis and the recommended runtime injection seam.
2026-07-16 02:46:25 -07:00
Dominic Cooney 8beadfbcd7 Probe all pwsh install locations for the Windows default shell.
The default-shell fallback only checked the Program Files pwsh path,
so Microsoft Store installs of PowerShell 7 fell back to Windows
PowerShell while VS Code's own terminal launched pwsh. Share one
candidate list between the sync default-shell check and the async
PowerShell prober. Also drop an 'as string' cast that hid the
setting's type from the checker.
2026-07-16 00:30:06 -07:00
Dominic Cooney 3105826a84 Rationalize shell identification and prompting, especially on Windows. 2026-07-15 23:46:39 -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
Tomás Barreiro 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
Max 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
Saoud Rizwan 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
Max 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
Saoud Rizwan adbb42a99c chore(sdk): release v0.0.61 2026-07-15 10:29:47 -07:00
Saoud Rizwan 50d1578a7e feat(ui): add shared Cline theme package (#12285)
* feat(desktop): refresh navigation and visual foundation

* feat(desktop): align settings with Cline Hub

* feat(desktop): improve chat markdown rendering

* fix(desktop): resolve review feedback blockers

* fix(desktop): refine inline code sizing

* fix(desktop): preserve workspace choices during startup

* feat(ui): add shared Cline theme package

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* fix(desktop): tighten settings navigation state

* fix(desktop): tighten markdown link handling

* fix(ui): harden theme contract validation

* test(desktop): cover late workspace restoration
2026-07-15 00:04:08 -07:00
Saoud Rizwan 5ef3b81369 feat(desktop): improve chat markdown rendering (#12276)
* feat(desktop): refresh navigation and visual foundation

* feat(desktop): align settings with Cline Hub

* feat(desktop): improve chat markdown rendering

* fix(desktop): resolve review feedback blockers

* fix(desktop): refine inline code sizing

* fix(desktop): preserve workspace choices during startup

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* fix(desktop): tighten settings navigation state

* fix(desktop): tighten markdown link handling

* test(desktop): cover late workspace restoration
2026-07-14 23:49:22 -07:00
Saoud Rizwan ec3a57771d fix(cli): block compaction during active turns (#12296) 2026-07-14 23:23:09 -07:00
Saoud Rizwan a695dab23a feat(desktop): align settings with Cline Hub (#12275)
* feat(desktop): refresh navigation and visual foundation

* feat(desktop): align settings with Cline Hub

* test(desktop): support webview component tests

* fix(desktop): resolve review feedback blockers

* fix(desktop): preserve workspace choices during startup

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* fix(desktop): tighten settings navigation state

* test(desktop): cover late workspace restoration

* chore: preserve upstream merge contents
2026-07-14 23:18:48 -07:00
Saoud Rizwan 77af52661c fix(telemetry): attach organization context to cached-credential identity (#12288)
* fix(telemetry): attach organization context to cached-credential identity

CLI cached credentials only stored the account id, so telemetry identity
resolved from them (headless runs via #11581, the hub daemon via #12177)
carried user_id but no organization_id - making CLI/hub usage invisible
to organization-scoped dashboards even where per-user attribution works.

- AuthSettingsSchema gains optional organizationId/organizationName/
  memberId
- loadClineAccountSnapshot persists the active organization into the
  cached cline provider settings after fetching /me (cleared when the
  user is on their personal account), so the context survives across
  processes without a network call
- the CLI runtime identify and the hub daemon identity refresh read the
  persisted fields and pass them to identifyAccount; the daemon re-keys
  its refresh on account+organization so an org switch re-identifies a
  long-lived daemon

* fix(telemetry): strip stray NUL byte, drop needless reshaping of daemon identity resolve
2026-07-14 23:10:27 -07:00
Saoud Rizwan 0f4acccd08 feat(desktop): refresh navigation and visual foundation (#12268)
* feat(desktop): refresh navigation and visual foundation

* test(desktop): support webview component tests

* fix(desktop): resolve review feedback blockers

* fix(desktop): preserve workspace choices during startup

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* test(desktop): cover late workspace restoration
2026-07-14 23:02:06 -07:00
Bee f8c73cd8cc feat(core): persist and refresh workspace git info (#12295) 2026-07-15 07:30:16 +02:00
Max 55a31a0d8a feat(vscode): add rollout telemetry to SDK extension (#12292)
* feat(vscode): add shared rollout telemetry contract

* feat(vscode-sdk): propagate rollout metadata

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-14 22:29:52 -07:00
Bee 04438c0d54 feat: shows compaction progress status in UI (#12137)
* feat: shows compaction progress status in UI

* fixes p2

* fix: complete compaction lifecycle delivery

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 13:09:10 +08:00
Bee f053ec48e4 refactor(llms): owns provider-specific header policy (#12187)
* refactor(llms): owns provider-specific header policy

* header
2026-07-15 03:23:23 +02:00
Bee 0df406723c refactor(core): normalize read file request path aliases (#12287)
* fix(core): normalize read file request path aliases

Accept `file_path` and `filePath` in read file requests and normalize them to the canonical `path` field. Apply alias handling to direct, array, and nested inputs to prevent model-generated variants from failing validation.

Clarify path descriptions by removing redundant wording.

* update test
2026-07-15 08:46:00 +08:00
Dominic Cooney 12703bf407 Improve VS Code terminal reliability: OSC 633 parser, exit codes, timeout handling (#11972) 2026-07-14 16:34:36 -07:00
Bee 4a97b46f5f refactor(core): simplifies context compaction trigger (#12217)
* refactor(core): simplifies context compaction trigger

Simplifies automatic context compaction so it always triggers when input usage reaches 80% of the model’s effective maximum input-token limit.

* add bound

* feedback apply

* complete

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 02:11:38 +08:00
Saoud Rizwan 36fc3327ac fix(cli): highlight API key fallback hint (#12283) 2026-07-14 10:51:05 -07:00
Max 2b48dc411f make old tasks incompatible with new cline extension (#12127)
Preserve pretty legacy task display after resume

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-14 10:00:23 -07:00
Tomás Barreiro da6fe718d0 Rename sessionStartedAt to sessionStartedAtMs (#12279) 2026-07-14 16:08:17 +02:00
Tomás Barreiro 3515333e23 Review against camelCase telemetry (#12280) 2026-07-14 15:59:19 +02:00
Saoud Rizwan bd9ac5872b ci(sdk): create GitHub release and post to Slack on latest SDK publish (#12223)
* ci(sdk): create GitHub release and post to Slack on latest SDK publish

* ci(sdk): use random heredoc delimiter for changelog output
2026-07-14 01:31:45 -07:00
Saoud Rizwan fb15324ad2 fix(cli): prevent use-after-free when setting terminal title during TUI teardown (#12229)
* fix(cli): prevent use-after-free when setting terminal title during TUI teardown

* fix(cli): re-check renderer destruction before title reset in teardown microtask

* test(cli): cover terminal title teardown lifecycle
2026-07-14 01:31:01 -07:00
Saoud Rizwan 7a27c04ffa fix(core): stop reporting benign git states as workspace init errors (#12189)
* fix(core): stop reporting benign git states as workspace init errors [ENG-2244]

A freshly initialized repo with no commits makes 'git rev-parse HEAD'
fail, which generateWorkspaceInfoWithDiagnostics recorded as a workspace
init error and surfaced as workspace.init_error telemetry on every
session bootstrap. Filter out git failures that reflect normal
repository states; genuine failures (missing directory, real git
breakage) are still reported.

* fix(core): drop 'bad revision' from benign git error filter

Review feedback: 'fatal: bad revision HEAD' can also indicate a corrupt
.git/HEAD (checkIsRepo still succeeds), which is a genuinely broken
workspace that should keep reporting. The remaining patterns cover the
empty-repo message variants.
2026-07-14 01:30:33 -07:00
Saoud Rizwan c37b252f65 fix(vscode): restore multi-root mention resolution and validate stored task cwd (#12190)
* fix(vscode): restore multi-root mention resolution and validate stored task cwd [ENG-2245][ENG-2244]

The SDK adapter's ensureWorkspaceManager() was a stub returning
undefined, which silently disabled multi-root file mention resolution:
parseMentions only searched the primary cwd, so @-mentions of files in
secondary workspace roots failed with not_found. Build a real
WorkspaceRootManager from the host's workspace folders (cached until
the folder set changes) via a new WorkspaceRootManager.fromPaths().

Also validate that a resumed task's stored cwdOnTaskInitialization
still exists before using it — stale paths (deleted/moved dirs) fed
git-based workspace init and produced init-error telemetry.

* fix(vscode): use JSON.stringify for workspace manager cache key

Review feedback: a delimiter-joined key is ambiguous for paths
containing the delimiter (and the previous separator was an embedded
NUL byte). JSON.stringify is unambiguous and order-preserving.

* test(vscode): cover stored task cwd validation
2026-07-14 01:30:21 -07:00
Saoud Rizwan 2872138900 feat: suggest model IDs from OpenAI-compatible endpoints in extension and CLI (#12231)
* fix(vscode): use the requested provider's stored credentials when listing OpenAI-compatible models

The OpenAI-compatible settings pane already fetches GET <baseUrl>/models to
suggest model IDs, but the host handler always read the built-in "openai"
provider's stored settings. Custom OpenAI-compatible providers only expose a
masked API key to the webview, so their model-list requests went out
unauthenticated and the suggestion dropdown stayed empty.

Add provider_id to OpenAiModelsRequest and read that provider's stored API
key and custom headers in refreshOpenAiModels. Old clients omit the field,
which defaults to "openai" and preserves the previous behavior.

* feat(cli): suggest model ids from OpenAI-compatible endpoints in the model picker

The CLI showed a bare free-text input for openai-compatible providers and
never asked the endpoint what it serves. Fetch GET <baseUrl>/models with the
provider's stored API key/headers when opening the picker; when the endpoint
answers, show the standard fuzzy list (which keeps the "Create custom model
ID" row for manual entry). Any failure or empty answer falls back to the
existing free-text input.

* fix: resolve OpenAI-compatible model discovery config
2026-07-14 01:26:48 -07:00
Saoud Rizwan dc4620c529 fix(vscode): add to system prompt about plan/act modes and nudge about mode switches (#12227)
* feat(shared): move plan/act mode prompt instructions into the shared prompt builder

The CLI's #12057 fixes (mode-tag explanation, plan-mode contract,
mode-switch notice tracker) were CLI-only wiring, so the VSCode extension
never told the model what the <user_input mode> attribute means and plan
mode kept mutating files (CLINE-2576, CLINE-2607, CLINE-2579). Promote
the pieces every host needs into @cline/shared:

- buildClineSystemPrompt now appends MODE_TAG_INSTRUCTIONS for every mode
  and PLAN_MODE_INSTRUCTIONS for plan sessions, composed into the rules
  slot in the exact order the CLI historically built by hand, so CLI
  output is byte-identical after the refactor.
- The plan-mode contract gains an explicit run_commands paragraph:
  the tool intentionally stays available in plan mode (essential for
  read-only investigation) but is inspection-only there -- no file
  mutations, no state-changing commands. The mitigation for plan-mode
  mutations is prompting plus mode-switch notices, not tool removal.
- createModeSwitchNoticeTracker moves from apps/cli/runtime/interactive
  to @cline/shared next to formatModeSwitchNotice; the CLI re-exports it
  so its import surface and tests stay unchanged.
- deriveTitleFromPrompt gets a regression test pinning that titles never
  pick up mode-notice text.

* fix(vscode): teach the model about plan/act modes and surface mode switches

Port the CLI's #12057/#12058 plan-mode fixes to the extension:

- The session factory drops its local PLAN_MODE_INSTRUCTIONS copy; the
  shared prompt builder now emits both the mode-tag explanation and the
  plan-mode contract (including the read-only run_commands rule), so the
  extension's system prompt finally explains the <user_input mode>
  wrapper its own messages have carried all along.
- Manual Plan/Act toggles record a mode-switch notice in
  SdkModeCoordinator (shared round-trip-cancelling tracker, scoped to
  the rebuilt session so it never leaks across tasks), recorded only
  after the session replacement actually commits. The model-initiated
  switch_to_act_mode path passes source: "tool" and records nothing,
  matching the CLI: its tool result and continuation prompt already
  announce the switch.
- SdkSessionLifecycle.fireAndForgetSend -- the single funnel for
  outbound turn sends -- consumes the notice and prepends
  formatModeSwitchNotice() to the next message, exactly like the CLI's
  run-interactive stamping.
- Display boundaries never render the raw tag: the queued-prompt echo
  in the message translator now goes through formatDisplayUserInput,
  and isSyntheticUserPrompt strips notices before matching so a stamped
  continuation prompt cannot shift edit/regenerate ordinals.
2026-07-14 01:26:20 -07:00
Saoud Rizwan 2ac5c85e69 fix(vscode): restore editor diff view for SDK edit tools (#12219)
* feat(sdk): expose edit-executor internals for host diff previews

Extract computePatchChanges() from createApplyPatchExecutor so hosts can
compute a patch's per-file proposed content without writing to disk
(behavior-identical refactor; the executor now calls the helper), and
widen the @cline/core root exports with createEditorExecutor,
createApplyPatchExecutor, computePatchChanges, PatchActionType and the
related types. Needed by the VS Code adapter to restore the editor diff
view for SDK edit tools.

* fix(vscode): restore editor diff view for SDK edit tools

Adds SdkDiffEditCoordinator, which owns per-toolCallId diff sessions over
the legacy DiffViewProvider abstraction (HostProvider factory, so the
external/JetBrains gRPC DiffService path keeps working):

- the diff editor opens populated before the approval ask renders (the
  SDK surfaces tool input only after the model stream completes, so the
  approval callback is the only pre-execution point with full input)
- an overridden editor executor saves through the diff document:
  user edits in the editable right pane and post-save auto-formatting
  flow back to the model via formatResponse.fileEditWithUserChanges,
  plus 'new problems' diagnostics
- Reject/abort reverts (new files: file + created dirs removed)
- auto-approved edits open the diff during execution with the legacy
  3.5s diagnostics settle; Background Edit keeps the headless disk path
- apply_patch gets a preview-only diff of its first changed file; on
  approve the preview is reverted and the untouched SDK executor applies
  the whole patch
- any diff-pipeline failure reverts and falls back to the SDK disk
  executor, preserving canonical error strings

Fixes #11934 (CLINE-2580).

* refactor(vscode): make edit diff preview a read-only virtual-document diff

Reworks the diff view restoration after EDH testing showed the editable
real-document design breaking on same-file multi-edits (tab reuse opened
the actual file instead of a diff; sibling saves closed other sessions'
tabs; right-pane edits misbehaved).

New design per review:
- EditPreview abstraction (mirrors CommentReviewController pattern):
  VscodeEditPreview renders vscode.diff with BOTH sides as virtual
  cline-diff documents (unique fragment per preview, so same-file edits
  get distinct tabs and close is an exact tab match, never the real
  file); ExternalEditPreview uses the existing openMultiFileDiff/
  closeAllDiffs host-bridge RPCs. New createEditPreview factory on
  HostProvider.
- The preview never touches disk: executors close the preview and
  delegate to the SDK's default disk executors, whose results and error
  strings reach the model unchanged. Reject/abort just closes a tab.
- Dropped by design decision: editing in the diff view, user-edit
  feedback to the model, and diagnostics passback (the SDK already
  prompts the model to check).
- Auto-approved edits show a brief preview that lingers ~1.5s after the
  write; an abort cuts the linger short without failing the applied edit.
- A newer same-file preview supersedes an older pending one (approvals
  resolve sequentially), eliminating cross-session interference.
- Legacy DiffViewProvider stack returns to untouched dead code.

* fix(vscode): state that denied edits did not modify the file

Repro: ask Cline to edit a file, then answer the approval with feedback
instead of Approve/Reject. The denial reached the model as just
{"error":"make them bigger"} — nothing said the edit was NOT applied —
so the model treated the feedback as iteration on an applied change and
built its next old_text against content that never landed on disk. From
then on old_text no longer matched the real file and the diff preview
silently stopped appearing (and the eventual executor run would fail the
same way).

Denial reasons now come from buildToolApprovalDenialReason(): edit tools
get 'The user denied this edit. The file was NOT modified and still
contains its original content.' (legacy parity), and all tools get user
feedback wrapped in <feedback> tags instead of the bare prompt as the
whole reason. isKnownToolApprovalDenial also matches the new edit-denial
marker so translator suppression keeps working.

* feat(vscode): simulated streaming animation for edit previews

Brings back the legacy 'yellow sweep' feel on the virtual diff preview.
The SDK only surfaces complete tool input, so this is a deliberate
simulation of the legacy streaming look (which legacy also showed when
it already had the full content in memory).

The sweep covers the whole file like legacy did, with diff-aware pacing:

- Park at the top: whole document under the faded-yellow overlay, cursor
  highlight on line 0, viewport pinned to the top, ~400ms hold so the
  animation unambiguously starts from the top.
- Zip through unchanged spans in small fast steps (~8 lines per 16ms
  frame, capped per span) so they read as continuous motion.
- Slow down through each change: one line per 45ms frame with a ~350ms
  minimum dwell per hunk so even a one-line change visibly pauses.
- Changed runs come from a real line diff (diffLines), so multi-hunk
  edits slow at EACH hunk and the gaps between hunks zip; pure deletions
  pause at the deletion point.
- Zip frames chase the cursor (InCenter) for continuous scroll; typing
  frames scroll only when leaving the viewport (no per-frame judder).
- After the sweep reaches the bottom: short beat, then settle centered
  on the first changed line for review.

Mechanics: edit previews move from base64-query cline-diff URIs to a new
mutable cline-edit-preview content provider (content set programmatically,
re-rendered via onDidChange) so the virtual right side can update in
place. DecorationController is reused as-is. The approval ask renders
while the animation plays (legacy simultaneity); close() cancels
mid-animation; files >3000 lines render the final diff immediately.
External hosts keep the static openMultiFileDiff preview.

* chore(vscode): remove test artifact comment from memory-monitor

* fix(vscode): address review nits — skip diff computation for large files, close partially-opened previews

- buildEditPreviewAnimation (which runs a full line diff) now runs after
  the MAX_ANIMATED_LINES guard; oversized files use a cheap prefix scan
  just to aim the viewport.
- If preview.open() throws after partially opening, the tab is closed
  directly — the session was never registered, so discardPreview could
  not have reached it.

* fix(vscode): keep tsconfig valid JSON for test setup

* fix(vscode): bound diff preview animation
2026-07-14 01:25:36 -07:00
Tomás Barreiro ab68fd7f34 Store startedAt in auth metadata when starting a Cline session (#12270)
* Store startedAt in auth metadata when starting a Cline session

* Inject the sessionStartedAt when creating the auth credentials

* Remove injecting sessionStartedAt when it's not stored already

* Address review

* fix merge inconsistencies
2026-07-14 03:50:00 +02:00
Saoud Rizwan b4ed8a226e chore(cli): release v3.0.40 2026-07-13 12:25:49 -07:00
Saoud Rizwan cbf40961db fix(hub): make markdown code component assignable to streamdown Components
The custom MarkdownCode node type used a narrow { metastring?: string }
shape that is not assignable from the hast Element passed by
react-markdown/streamdown, so a clean rebuild (fresh dependency resolve,
as done by the release version.ts) fails the `satisfies Components`
check. Widen node.properties to Record<string, unknown> and validate the
metastring value at read time.
2026-07-13 12:02:49 -07:00
Saoud Rizwan 2d05ba52da chore(sdk): release v0.0.60 2026-07-13 11:25:21 -07:00
Saoud Rizwan 5d3778b5cf feat(cli): manual API key escape hatch for Cline OAuth providers (#12254)
* feat(cli): manual API key escape hatch for Cline OAuth providers

Add a way to configure the cline / cline-pass providers with a dashboard
API key from the /settings provider flow, for users where OAuth login
isn't working:

- "Enter API key manually" option in the already-configured dialog
- K keybinding in the OAuth login dialog to switch to key entry
- Saving clears stored OAuth tokens (on both the shared cline storage
  entry and any direct cline-pass entry) since the auth handler prefers
  auth.accessToken over apiKey — a stale token would otherwise keep
  winning over the manual key
- isProviderConfigured now counts a persisted API key for OAuth
  providers so escape-hatch users aren't forced back into OAuth on
  every provider switch

* fix(cli): move API key fallback to OAuth dialog
2026-07-13 11:14:11 -07:00
Saoud Rizwan 8d0eb54a1f feat(telemetry): track auth refresh outcomes to measure the hard-logout fix (#12256)
* fix(sdk): stop misclassifying transient refresh failures as invalid_grant

getValidClineCredentials returned null for BOTH a rejected refresh token and
any transient error (network down, timeout, 5xx) that happened to land after
the access token expired. Callers treat null as 'session dead' — the
extension wipes providers.json over it, logging out every Cline process on
the machine, which is what CLI users then hit as 'Unauthorized: please
re-authenticate'. A laptop waking from sleep past the ~1h token expiry with
a background job (balance/banners/remote-config) refreshing before the
network is up was enough to trigger it — no refresh-token rotation involved.

Now: null means the refresh token was REJECTED (re-auth required); transient
failures throw so callers keep stored credentials and retry later. The
extension's refreshAccessToken catch and the CLI's error surface already
handle the throw correctly with no changes.

* fix(sdk): write providers.json atomically

providers.json was written with a bare writeFileSync while being read
concurrently by every other Cline process (CLI, extension, hub). A reader
catching a partial write parses garbage, which read() silently treats as
EMPTY settings — indistinguishable from being logged out — and any
subsequent save from that process persists the empty state, erasing every
configured provider.

Stage to a pid-unique temp file and rename into place; rename is atomic on
POSIX and replaces on Windows, so readers only ever see a complete file.

* feat(telemetry): track auth refresh outcomes to measure the hard-logout fix

Adds the observability needed to verify in production that the
transient-vs-invalid_grant fix is working, and to diagnose any logouts that
remain:

- user.auth_refresh_soft_failure — fires when a refresh fails for a reason
  that does NOT invalidate the session (network error, timeout, 5xx) and
  stored credentials were kept. Instances with tokenExpired=true were hard
  logouts before the fix, so this is the 'prevented logout' counter. Emitted
  from the SDK (CLI path) and from the extension's refresh/restore catches
  under the same event name so dashboards aggregate both clients.
- user.auth_logged_out now carries the HTTP status and errorCode that caused
  it, and the extension emits it (with a distinct reason) at every site that
  clears providers.json: refresh_rejected, restore_refresh_rejected, and
  handleDeauth's LogoutReason (user_initiated / cross_window_sync / …), which
  was previously accepted and ignored. Extension-triggered logouts were
  completely invisible before — including the legacy-extension cross-window
  cascade, which this now measures directly.

Success looks like: auth_logged_out volume drops after release while
auth_refresh_soft_failure appears in its place, and any remaining logouts
carry a reason/status we can act on.

* fix(telemetry): route auth refresh events through SDK
2026-07-13 11:13:05 -07:00
Saoud Rizwan a3989acc38 fix(sdk): don't log users out when token refresh fails due to network/server errors (#12255)
* fix(sdk): stop misclassifying transient refresh failures as invalid_grant

getValidClineCredentials returned null for BOTH a rejected refresh token and
any transient error (network down, timeout, 5xx) that happened to land after
the access token expired. Callers treat null as 'session dead' — the
extension wipes providers.json over it, logging out every Cline process on
the machine, which is what CLI users then hit as 'Unauthorized: please
re-authenticate'. A laptop waking from sleep past the ~1h token expiry with
a background job (balance/banners/remote-config) refreshing before the
network is up was enough to trigger it — no refresh-token rotation involved.

Now: null means the refresh token was REJECTED (re-auth required); transient
failures throw so callers keep stored credentials and retry later. The
extension's refreshAccessToken catch and the CLI's error surface already
handle the throw correctly with no changes.

* fix(sdk): write providers.json atomically

providers.json was written with a bare writeFileSync while being read
concurrently by every other Cline process (CLI, extension, hub). A reader
catching a partial write parses garbage, which read() silently treats as
EMPTY settings — indistinguishable from being logged out — and any
subsequent save from that process persists the empty state, erasing every
configured provider.

Stage to a pid-unique temp file and rename into place; rename is atomic on
POSIX and replaces on Windows, so readers only ever see a complete file.
2026-07-13 08:58:26 -07:00
Saoud Rizwan d199b1bff9 fix(desktop-app): allow loopback origins for Next dev resources (#12251)
Next 16 blocks dev-resource requests (/_next/webpack-hmr, dev fonts) from
origins that don't match the dev server's own hostname. Browsing the web
dev mode via 127.0.0.1 left the page hanging with 'Blocked cross-origin
request to Next.js dev resource' warnings. allowedDevOrigins is dev-only,
so production/Tauri builds are unaffected.
2026-07-12 22:18:45 -07:00
Saoud Rizwan d41eed1198 feat(desktop-app): env-configurable sidecar bind host, trusted origins, and webview WS endpoint (#12250)
* feat(desktop-app): env-configurable sidecar bind host, trusted origins, and webview WS endpoint

Allows running the desktop app's web dev mode (dev:web + dev:sidecar) inside
a Docker container with published ports:

- CLINE_SIDECAR_HOST: sidecar bind hostname (default remains 127.0.0.1)
- CLINE_SIDECAR_TRUSTED_ORIGINS: comma-separated extra browser origins for
  the sidecar's origin allowlist (validation itself stays on)
- NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: overrides the webview's hardcoded
  ws://127.0.0.1:3126/transport fallback so a browser on the Docker host can
  dial the published port

All defaults are unchanged, so local/Tauri behavior is unaffected when the
env vars are absent. When bound to 0.0.0.0 the printed ready endpoint
advertises 127.0.0.1 since a wildcard bind is not dialable.

* chore(desktop-app): untrack next-env.d.ts

It was added to .gitignore previously but never removed from the index, so
it kept showing as modified: Next.js rewrites the routes.d.ts import path
depending on whether 'next dev' or 'next build' ran last. The file is
regenerated by Next on every dev/build run, and the app's typecheck
(tsconfig.dev.json) excludes webview/, so nothing needs it tracked.

* style(desktop-app): format SIDECAR_HOST declaration
2026-07-12 21:59:07 -07:00
Tomás Barreiro 6309971089 Add the ClinePass limit error to the CLI (#12191)
* Add the ClinePass limit error to the CLI

* Update apps/cli/src/runtime/run-agent.test.ts

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

* format code and improve instructions

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-11 02:41:55 +02:00
Saoud Rizwan 2d2c669421 fix(cli): reload provider config when switching models (#12232) 2026-07-10 16:57:50 -07:00
Dominic Cooney c5f146a418 Add debug logging for Cline credential lifecycle (ENG-2213) (#12000)
* fix(auth): add early SDK debug logging for Cline credential lifecycle (ENG-2213)

Adds targeted debug-level logging at key points in the Cline/Cline Pass
credential lifecycle to diagnose intermittent logout issues. Credentials
are never logged in cleartext; an 8-hex-digit SHA-256 hash is used instead.

The SDK has two logger layers:
1. ClineCore.logger — session-scoped, threaded from ClineCore.create({logger})
   into session config and the agent event bridge.
2. setSdkLogger() — early/module-level, for components that operate before
   or outside of ClineCore sessions: ProviderSettingsManager (constructed
   at startup), RuntimeOAuthTokenManager, and cline.ts auth functions
   (token refresh). These can't be reached by the session-scoped logger.

Both VS Code (common.ts) and CLI (main.ts) call setSdkLogger() once at
startup. When no logger is registered (or the host filters out debug),
every call is a no-op — logging is never collected in normal use.

Instrumentation points (SDK core, shared by both surfaces):
- ProviderSettingsManager.read(): logs provider IDs, last-used, and whether
  Cline auth is present (with hashed access/refresh token fingerprints)
- ProviderSettingsManager.saveProviderSettings(): logs the provider being
  saved, tokenSource, whether Cline auth was present before/after, and
  flags authDropped when a previously-present Cline auth block disappears
- RuntimeOAuthTokenManager.resolveProviderApiKeyInternal(): logs each
  decision point (no_settings, no_credentials, refresh_start, refresh_null,
  refreshed+saved, not_refreshed) with hashed token fingerprints
- cline.ts refreshClineToken(): logs the refresh request URL, response
  status/errorCode on failure, and new token hashes on success
- cline.ts getValidClineCredentials(): logs the outcome at each branch
  (no_current_credentials, still_valid, needs_refresh, invalid_grant,
  transient_failure_kept_current, transient_failure_expired)

VS Code extension (auth-service.ts):
- readClineCredentials/writeClineCredentials/clearClineCredentials: logs
  credential presence and hashes at each disk I/O point
- refreshAccessToken: logs refresh start, null result (cleared), changed
  (written), or unchanged outcomes
- fetchUserInfoFromApi: logs the GET /api/v1/users/me request and response
  status

What to collect when investigating:

VS Code extension:
- Open the "Cline" output channel (View -> Output -> select "Cline")
- Look for lines containing: [SdkAuthService], providers.read,
  providers.save, oauth.resolve, cline.refresh, cline.getCredentials
- Debug logging is emitted at the DEBUG level; it appears in the output
  channel when IS_DEV=true or in development builds

CLI:
- Set CLINE_LOG_LEVEL=debug environment variable before running cline
- Collect the log file at ~/.cline/data/logs/cline.cli.log (or the path
  set by CLINE_LOG_PATH)
- Look for the same event names as above

Files changed:
- sdk/packages/core/src/auth/auth-debug.ts (NEW): hashSecret,
  setSdkLogger, getSdkLogger, sdkDebug
- sdk/packages/core/src/auth/cline.ts: refresh/getCredentials logging
- sdk/packages/core/src/services/storage/provider-settings-manager.ts:
  read/save logging
- sdk/packages/core/src/runtime/orchestration/runtime-oauth-token-manager.ts:
  resolve logging
- sdk/packages/core/src/index.ts: export early logger utilities
- apps/vscode/src/sdk/auth-service.ts: credential lifecycle logging
- apps/vscode/src/common.ts: register SDK early logger
- apps/cli/src/main.ts: register SDK early logger

* fix(vscode): inline SDK debug metadata into log message string (ENG-2213)

* fix(auth): gate debug logging on CLINE_LOG_LEVEL at runtime (ENG-2213)

* fix(auth): use interpolated debug strings, remove log-level gating (ENG-2213)

* refactor: move early logger to sdk/packages/core/src/logging/early-logger.ts

* fix: address review feedback — early logger registration, log after write, remove getSdkLogger from public API

* fix(vscode): add ISO timestamps to all log lines

* fix core import

* fix import

* fix tests

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-07-10 23:40:46 +02:00
Saoud Rizwan 261ee4c313 fix(vscode): show requested line range on read-file chat rows (#12225)
The webview already knew how to render readLineStart/readLineEnd on
readFile tool rows, but the SDK message translator never populated
them, so successive ranged reads of the same file all rendered as
identical bare paths. Extract start_line/end_line from read_files
input (per-file and single-path forms) and render open-ended reads
(start_line only) as "start+".
2026-07-10 13:25:30 -07:00
Saoud Rizwan d45b051c04 fix(cli): detect bun global installs after symlink resolution in auto-update (#12224) 2026-07-10 12:15:26 -07:00
Bee 78c83cdf33 fix(cli): preserve session id when in same session (#12188) 2026-07-10 17:59:55 +08:00
Bee 3266121fa1 feat(desktop): add typography spec (#12215)
* feat(desktop): add typography spec

* remove unused background component
2026-07-09 19:34:28 -07:00
Bee 6467de65a2 fix(plugin): follow up fix for agent-squad (#12216)
Follow up on my last PR where the last commit revert the removal of the regex field from zod schema
2026-07-09 19:33:04 -07:00
Bee 65fe885638 fix(sdk): remove regex from zod schema for agent-squad plugin example (#12214)
* fix(sdk): remove regex from zod schema for agent-squad plugin example

The `HandoffPathInput` schema used negative lookaheads to reject absolute paths and `..` traversal segments. When converted to JSON Schema, this regex caused consumers without lookaround support to fail with `invalid JSON schema: regex lookaround is not supported`.

This change removes the lookaround-based regex from the published schema and moves those checks to runtime validation. It preserves validation for allowed characters, absolute paths, traversal segments, and maximum length while strengthening cross-platform directory containment checks using Node’s path utilities.

* add back logger examples
2026-07-10 10:19:27 +08:00
Bee c3033d6f13 fix(vscode): refreshGroqModels caused cacheReadsPrice undefined error (#12213) 2026-07-10 08:52:06 +08:00
Max cfb1327a1b fix vscode hmr not working (#12212) 2026-07-09 16:49:35 -07:00
Alex Taboada 264af96e1b fix(vscode): prevent infinite loading when initializing task with an image (#12203) 2026-07-09 18:16:52 +02:00
Robin Newhouse 10cb9bd97a Add compaction budget hardening (#12142)
* Add compaction budget projection contract

* Tighten budget projection contract types

* Tighten dropped block action paths

* Add pure compaction budget projection engine

* Fix budget projection truncation accounting

* Drop provider-native blocks during budget projection

* Recompute protected tail after thinking pruning

* Align budget projection test tool results

* Clean up budget projection fixture indentation

* fix(core): narrow compaction protected tail

* Fix budget projection action accounting

* Budget agentic compaction summary input

* Harden agentic summary budget fallback

* Align agentic compaction test tool result

* Align agentic file ops with projected input

* Budget basic compaction projections

* Clarify basic projection budget logging

* Align basic sanitization image expectation

* Align basic compaction budget expectation

* Emit compaction budget emergency telemetry

* Tighten compaction budget telemetry types

* Preserve compaction status notice reasons

* fix(core): account compaction tokens consistently

* fix(core): align skipped compaction token accounting
2026-07-09 02:15:55 -07:00
Saoud Rizwan 2ee18e7f0c chore(cli): release v3.0.39 2026-07-08 21:15:22 -07:00
Saoud Rizwan 0b65506a2b chore(sdk): release v0.0.59 2026-07-08 20:00:37 -07:00
Saoud Rizwan 3502608081 fix(telemetry): emit telemetry from the detached hub daemon process (#12177)
* feat(sdk): emit telemetry from the hub daemon process

The detached hub daemon hosts the LocalRuntimeHost that emits
task.conversation_turn and task.tokens for every hub-backed session
(CLI in prefer-hub mode, desktop app, connectors), but the daemon
entrypoint never created a telemetry handle - startHubWebSocketServer
received telemetry: undefined and every capture in the daemon-side
runtime was a no-op. Sessions billed normally on the backend while
reporting nothing to OTel.

- create a ConfiguredTelemetryHandle in the daemon entry and pass it to
  the websocket server and schedule runtime handlers
- identify from the cached cline account at startup and re-resolve
  periodically, since the long-lived daemon often starts before login
  or outlives an account switch
- flush and dispose the handle on graceful and fatal shutdown

* fix(sdk): flush daemon telemetry when server startup fails

If startHubWebSocketServer throws, dispose the telemetry handle before
rethrowing so failed daemon starts are visible in telemetry instead of
dying silently.

* fix(sdk): bound daemon telemetry flush and reuse settings manager

- Race dispose's flush against a 5s deadline so a hung exporter can't
  keep a crashed daemon alive holding the hub port (before this PR the
  daemon exited immediately on fatal errors; the flush must not change
  that materially).
- Construct ProviderSettingsManager once instead of every identity
  refresh; its constructor runs legacy-migration and provider
  registration side effects, and getProviderSettings re-reads the file
  per call anyway.
- Test the dispose-on-startup-failure path and the cline-hub-daemon
  platform metadata.

* fix(sdk): label daemon telemetry cline_type as hub

Review feedback from @abeatrix: daemon-hosted sessions can be triggered
by the CLI, desktop app, or connectors, so daemon-emitted events should
not share the CLI process's cline_type. Existing values are "cli" and
"VSCode Extension"; the daemon now reports "hub" (with the finer
platform=cline-hub-daemon kept as-is).
2026-07-08 19:35:26 -07:00
Saoud Rizwan ed3107f9ec Revert "docs: add Cline free models page (#12183)" (#12185)
This reverts commit 6bce48aad4.
2026-07-08 19:32:36 -07:00
Renee Huang 6bce48aad4 docs: add Cline free models page (#12183)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 19:22:32 -07:00
Bee 1e1b6af51c fix(sdk): set versioned Cline client-identity headers for Cline provider (#12182)
* fix(sdk): set versioned Cline client-identity headers for Cline provider

* address feedback

* feat: add platform metadata to client context

Include platform, platformVersion, and isMultiRoot in extension client
context for CLI, ACP, and VS Code sessions. This provides downstream
core/session logic with richer runtime information and distinguishes ACP
clients from the standard CLI client.

* lint

* clean up

* fix: resolve client host identity via HostProvider for standalone compatibility

cline-session-factory.ts is also bundled into the standalone cline-core
(JetBrains), where the 'vscode' module resolves to the generated Proxy-stub
module: vscode.env.appName and vscode.version return Proxy objects, which
would flow into X-PLATFORM/X-PLATFORM-VERSION header values and fail at
request serialization.

Resolve the identity through HostProvider.env.getHostVersion() instead —
the VS Code hostbridge returns the identical values (vscode.env.appName,
vscode.version, ClineClient.VSCode, extension version), and JetBrains'
hostbridge returns its real host values, so the standalone stops reporting
itself as the VS Code extension as a bonus. Multi-root detection goes
through HostProvider.workspace.getWorkspacePaths() for the same reason.
Both resolvers degrade gracefully (undefined/false) if the host bridge is
unavailable, in which case the header builder falls back to source-derived
values.

* Add unit test as proof

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 19:08:25 -07:00
Saoud Rizwan ee49900232 chore(greptile): update telemetry review rules for the monorepo layout (#12180)
The .greptile config was written for the pre-merge standalone cline/sdk
repo and never updated after the monorepo merge:

- the sdk-telemetry-doc-update rule enforced an Event Catalog in DOC.md,
  a file that does not exist in this repo (it now emits a false P2 on
  every PR touching core-events.ts, e.g. #12177)
- rules.md cited PR #357, apps/vscode/src/hub-daemon.ts, and
  apps/vscode/src/telemetry.ts - none of which exist here
- the 'Hub Daemon Metadata Forwarding' section described an argv-based
  metadata payload that was never implemented in this repo; replaced
  with the actual daemon-owned telemetry pattern from #12177
- the opted-out-test rule now describes the real convention: assert the
  event flows through capture (no-op for OptedOutTelemetryService), not
  captureRequired
2026-07-08 16:43:14 -07:00
Saoud Rizwan a1d5589d19 feat: allow selecting Cline free models on the ClinePass provider (#12140)
* feat(llms): include Cline free models in the cline-pass catalog

* feat(vscode): show Subscribed/Free model tabs on the ClinePass provider

* feat(cli): show Subscribed/Free sections in the ClinePass model picker

* fix(cli): drop redundant browse-all entry from ClinePass picker

* fix(cli): show only subscribed models in ClinePass onboarding picker

* feat(cli): include free models and quota explainer in ClinePass onboarding picker

* fix: shorten ClinePass free section copy

* fix(cli): strip redundant free markers from sectioned picker names

* fix: drop free from ClinePass free section copy

* fix: tighten ClinePass free section copy

* refactor: address review feedback on ClinePass free models

- single buildFeaturedModelEntries(providerId) dispatcher, builders private
- rename isClineProvider to isClineManagedProvider (includes cline-pass)
- use isClineManagedProvider in the free-model cost check
- themed tab border, pretty names on free model cards
- clearer cline-pass cost test name

* fix: address ClinePass free-model review blockers

- Stop re-sorting the cline-pass live catalog by release date in
  mergeKnownModels: free models carry OpenRouter release dates, so the
  sort could put a free model first and make it the fallback default
  when the bundled default id rotates out of the live clinePass bucket.
  Preserve the normalize-time order (pass models first) and pin it with
  an end-to-end resolveProviderConfig test.
- Add the browse-all escape to the CLI ClinePass picker when the
  clinePass bucket is empty (bundled fallback after a fetch failure),
  so a subscriber isn't left with a free-models-only picker.
- Rename ErrorRow's local isClineManagedProvider to
  isClineUsageBillingProvider: it only matches the cline provider,
  unlike the shared util of the same name that also matches cline-pass.
2026-07-08 15:08:27 -07:00
Tomás Barreiro 721fda2e99 Add ClinePass limit error (#12162)
* Add ClinePass limit error

* refactor regex

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 14:54:20 -07:00
Saoud Rizwan 5e78861eb5 fix(vscode): update ClinePass onboarding option copy (#12173) 2026-07-08 13:21:12 -07:00
Robin Newhouse 29798f59f3 Persist VS Code manual compaction sidecar (#11900)
* Persist VS Code manual compaction sidecar

* Fix compaction test isolation

* Address PR feedback on compaction comments

* Fix compaction test core mock hoisting

* fix(vscode): avoid compaction session rebuild

* fix(core): validate active compaction from persisted transcript

* fix(vscode): harden manual compaction sidecar flow
2026-07-08 13:18:22 -07:00
Ara 177d0eb07f Remove Cline model picker recommendation copy (#12170) 2026-07-08 12:15:53 -07:00
Bee 869a87a220 fix(core): use no-emit TypeScript config for checks (#12139)
* fix(core): use no-emit TypeScript config for checks

Update the core package TypeScript config to run checks without emitting files,
allowing broader workspace sources via the package parent rootDir. Simplify the
dev config so it only extends the main package config and avoids duplicated
compiler overrides.

* feedback

* remove dead code
2026-07-08 11:15:16 -07:00
Max 0cfd0bbe05 Fix VS Code F5 webview debug flow (#12027)
* fix vscode f5 settings

- fixed the hot module reloading issue while debugging the extension.
- also fixed issue where deb:webview task wasn't showing as complete

* fix vscode webview dev cleanup

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-08 10:53:41 -07:00
yanalialiuk 08f656532f docs: add Atomic Chat local provider setup guide (#11966)
* docs: add Atomic Chat local provider setup guide

Document Atomic Chat alongside Ollama and LM Studio in the local models
overview and add a dedicated provider configuration page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update overview.mdx

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-07-08 10:48:58 -07:00
Tomás Barreiro 10dece6677 Remove all ClinePass GLM 5.1 references (#12107)
* Remove all ClinePass GLM 5.1 references

* fix other references
2026-07-08 14:59:10 +02:00
Sufiyan Khan 885a2936b6 docs(authorizing): remove model-specific wording from generic setup step (#12156)
Step 4 in the IDE setup flow says 'Choose your desired Claude model'
but applies to all providers (OpenAI, Gemini, DeepSeek, local, etc.).
Drop 'Claude' to keep it provider-agnostic.
2026-07-08 21:22:33 +09:00
Dominic Cooney 90c427740d perf(sdk): stop listSessions hot loop from hanging the extension host (#11967)
* perf(sdk): stop listSessions hot loop from hanging the extension host

getStateToPostToWebview rebuilt the full task history on nearly every
streaming/session event, and each rebuild ran persistence-service.listSessions,
which synchronously read + Zod-parsed every session manifest. The 10s metadata
cache meant to absorb this was wiped on every per-turn updateTaskUsage, so each
state post paid the full synchronous scan, saturating the extension-host event
loop (observed as a tight listSessions/readFileUtf8 loop in CPU profiles).

- Debounce/coalesce postStateToWebview: trailing 50ms debounce plus a single
  queued follow-up so bursts collapse into one rebuild; dispose() tears it down.
- Add an async, title-only manifest reader (readSessionManifestTitle) and use it
  in listSessions to resolve titles concurrently off-thread, instead of a
  synchronous readFileSync + full SessionManifestSchema (Zod) parse per row. The
  existing sync manifest methods are left intact.
- On single-session updates, patch just the changed record in the merged-history
  cache in place instead of invalidating it, so frequent per-turn usage updates
  no longer force the next state post to re-enumerate and re-merge every session.

* refactor(sdk): strengthen session history cache patching

Replace patchMetadataHistoryCacheRecord (boolean-returning, metadata-only,
no re-sort) with updateCachedSessionRecord (void, updates prompt +
metadata + updatedAt, re-sorts via shared comparator).

- Void return eliminates the ignorable fallback contract.
- Mirrors all fields the persistence layer writes (prompt, metadata,
  updatedAt) so cache and disk stay consistent.
- Re-sorts after patching so the updated record bubbles to the correct
  position, using a shared compareSessionHistoryRecordsByRecencyDesc
  comparator also used by listHistory.
- Derives updatedAt from the HistoryItem timestamp instead of constructing
  a second clock value.
- Self-invalidates on cache miss so callers never manage the fallback.

Adds tests for in-place patching, re-sorting, per-turn usage hot path,
and cache-miss invalidation.

* fix(sdk): await in-flight state post during dispose

Greptile feedback: dispose() did not await a concurrently-running
runDebouncedStatePost, so an in-flight flushStateToWebview could access
torn-down resources after disposal.

Track the runDebouncedStatePost promise in statePostInFlightPromise.
In dispose(), after setting isDisposed and clearing the timer, await
the in-flight promise (swallowing errors) before tearing down downstream
resources. The !this.isDisposed guard in the loop prevents further
iterations after disposal.

* fix(sdk): address review feedback on state-post debounce and cache patch

Three issues from code review of the listSessions hot-loop fix:

1. dispose() could await the wrong promise. A second debounced timer
   firing while a flush was already running overwrote
   statePostInFlightPromise with a throwaway resolved promise from the
   join path, so dispose() could return while the original flush was
   still executing. Extract the debounce/coalesce state machine into
   StatePostDebouncer, and only track the promise from the call that
   actually starts a new flush loop.

2. postStateToWebview() swallowed flush errors, resolving every pending
   caller even when flushStateToWebview() threw. Callers awaiting
   postStateToWebview() now see the rejection, matching pre-debounce
   behavior.

3. Cache patching derived the cached updatedAt from HistoryItem.ts,
   but the persistence adapter always stamps updatedAt with the
   wall-clock write time. Callers like toggleTaskFavorite() reuse an
   old HistoryItem whose ts predates the write, which let the cached
   ordering diverge from disk until the 10s TTL expired. Stamp the
   cache patch with the write time instead.

Adds unit tests for StatePostDebouncer covering the dispose race and
error-propagation regressions, and a sdk-task-history test for the
stale-updatedAt cache-ordering regression.

* fix(sdk): don't patch cache when session update write didn't land

Beatrix's review feedback: updateSession() ignored the { updated:
boolean } result from host.update() and unconditionally patched the
metadata cache. When persistence returns updated: false (session
deleted/missing, or an optimistic-concurrency retry exhausted by a
racing writer), the webview could show a fake updated record until the
cache TTL expired.

Check the write result: only patch the cache when updated === true,
otherwise invalidate it so the next read re-enumerates from disk.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-08 13:23:18 +09:00
alex-lum e6028168f2 fix(sdk/cli): emit user_id in SDK/CLI telemetry identity attributes (CLINE-2406) (#11581)
* fix(sdk/cli): emit user_id in telemetry identity attributes

Per CLINE-2406, downstream analytics expects an explicit user_id field
in authenticated SDK/CLI OpenTelemetry log attributes.

Changes:
- sdk/packages/core/src/services/telemetry/core-events.ts: add
  user_id: account.id alongside the existing account_id in
  identifyAccount() updateCommonProperties call.
- sdk/packages/core/src/services/telemetry/core-events.test.ts: new
  identifyAccount suite verifying user_id, account_id, distinct_id, and
  org context fields for authenticated user without org, with active org,
  absent/blank id handling, and no-op when telemetry is undefined.
- apps/cli/src/main.ts: after loading Cline provider settings in the
  runtime path, read auth.accountId and call identifyTelemetryAccount so
  subsequent task.* and workspace.* events carry user_id. Document
  user.extension_activated as pre-auth by design for subcommand flows.
- apps/cli/src/main.test.ts: three new tests covering saved accountId
  triggers identity, missing accountId skips identity, non-Cline
  provider skips identity.

* fix(sdk/cli): address review feedback on telemetry identity

- Use trimmed distinctId for user_id in identifyAccount() to keep
  user_id and distinct_id consistent when IDs have whitespace
- Remove fragile type cast in CLI main.ts; ProviderSettings already
  exposes auth.accountId via AuthSettingsSchema
2026-07-07 19:20:03 -07:00
Bee c3f75b3ff0 chore: Cline Code Desktop App update (#12012)
* wip: Cline Code Desktop App

Add Bun/Tauri desktop packaging commands for macOS, Windows, and Linux, including output to dist/desktop. Enforce macOS signing and notarization requirements for shareable builds while allowing an explicit unsigned local test path.

Document desktop packaging prerequisites, ignore generated build artifacts, and wire runtime session connection updates needed by the desktop app.

Clean up and update sidecar functions.
Safe to merge as this is not a published app.

* fixes

* chat

* apply

* ClinePass support

* add build instructions and use system theme

* fix: diff status

* update tool calls display

* connection updates

* lint fix

* fix keydown
2026-07-08 09:08:34 +08:00
Bee 88ce3e0b11 fix(core): emit accurate str_replace diffs (#12102)
* fix(core): emit accurate str_replace diffs

* fixes
2026-07-07 16:02:17 -07:00
Bee dd719dce86 fix(llms): OpenAI Codex model metadata for GPT Subscription provider (#12129)
* fix(llms): OpenAI Codex model metadata for GPT Subscription provider

* add unit tests

* Update stale unit tests

* clarify doc string

* Update docs format

* update old test
2026-07-07 15:57:21 -07:00
Robin Newhouse d5db7eb853 Preserve canonical session history during compaction ENG-1967 (#10651)
* Preserve canonical history with compaction sidecar

* Clarify prepareTurn request projection semantics

* Harden hub compaction sidecar ownership

* Handle compaction sidecar edge cases

* Address compaction sidecar review feedback

* Tighten compaction sidecar safety

* Extract atomic session file writes

* Assert compaction boundary role delimiter

* Simplify compaction source hashing

* Fix compaction smoke test type guard

* Fix async interactive runtime tests

* Avoid dangling compaction path in manifests

* Address compaction sidecar review nits

* fix(cli): await async runtime helper in restart test
2026-07-07 13:19:05 -07:00
Max 11d5ebe8bc chore: schedule nightly VS Code extension publish (#12124)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-07 10:30:04 -07:00
Saoud Rizwan 6f7cc4907f chore(cli): release v3.0.38 2026-07-06 19:08:18 -07:00
Saoud Rizwan 27e3541569 chore(sdk): release v0.0.58 2026-07-06 18:52:52 -07:00
Bee ae9c5b4d9d fix(core): tolerate orphan line-range entries in read_files input (#12104)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-06 18:48:50 -07:00
Bee f86ca6b36b fix(test): fix stale palette tests (#12105)
Commit 9a9300846 ("restyle chat input…", which folded in PR #12075 "replace cyan accent with new plan/act palette") deliberately rebranded the TUI accents in palette.ts:

Dark act: ANSI "cyan" → #79b8ff (and plan "yellow" → #ffea7f, success "brightGreen" → #99e89b)
Light act: #0969da → #0f72cb (and plan #9a6700 → #867100), re-derived in OKLCH to keep the same hue as the new dark accents with ≥4.5:1 contrast on white
But palette.test.ts:27-35 still asserts the old values ("preserves the existing named ANSI colors" — a test description that's now literally obsolete). So getModeAccent("act", "dark") correctly returns #79b8ff, and the test expecting "cyan" fails.

The fix is to update the two tests to the new palette values (and rename the first test, since the colors are no longer named ANSI colors).
2026-07-06 18:23:49 -07:00
Saoud Rizwan 0e0b11032e chore(sdk): release v0.0.57 2026-07-06 18:06:43 -07:00
Saoud Rizwan a93d850aee feat(cli): tint assistant markdown accents by the mode they were produced in (#12101)
* feat(cli): tint assistant markdown accents by the mode they were produced in

Markdown's prominent elements (headings, bold, list markers, links) were
hardcoded to the act accent. getSyntaxStyle now takes the entry's mode
and colors those elements with the matching accent -- plan segments
render yellow-tinted markdown, act segments blue -- completing the
per-mode transcript coloring. Code token colors stay constant across
modes; styles are cached per theme+mode pair. Unstamped entries follow
the current mode, same fallback as the glyph accent.

* fix(cli): resolve entry mode once for glyph and markdown accents

Address review: the accent and mode props used parallel fallback chains
that could drift; both now derive from a single resolved entryMode. Also
cover the light-theme plan/act markdown accents in tests.
2026-07-06 17:21:45 -07:00
Saoud Rizwan fe25258b0d feat(cli): polish status bar usage display and ClinePass model name (#12077)
* feat(cli): restyle chat input with horizontal rules and slim user bubbles

Replace the tinted-background input field with a minimal frame: full-width
horizontal rules above and below the textarea and a bold accent-colored
prompt glyph, with no background fill. User message bubbles in the
transcript match the new look: a slim neutral-gray bar with the same
glyph, no vertical padding, and no mode-colored tint.

Palette gains getInputRuleColor (neutral adaptive mid-gray for the rules)
and getUserMessageBackground (neutral bubble tint), both built on a shared
OKLAB lift helper extracted from getModeInputBackground. Home view's
robot cursor-tracking offset is adjusted for the input's new left edge,
and the spacer line between the chat input and status bar is removed.

* feat(cli): replace cyan accent with new plan/act palette

Swap the TUI accent colors: act mode goes from ANSI cyan to #79b8ff and
plan mode from ANSI yellow to #ffea7f on dark themes. Light themes get
counterparts derived in OKLCH with the same hue but darkened to hold
>=4.5:1 contrast on white (#0f72cb act, #867100 plan), selected via the
existing getModeAccent theme switch.

All hardcoded "cyan" fg literals across dialogs, model selector, config
view, and onboarding now reference palette.act so the accent is a single
source of truth. The selection highlight follows the act color. The
subliminal OKLAB chroma nudge on input backgrounds/foregrounds now leans
blue (-a, -b) instead of cyan (-a, +b) to match the new accent hue.

* feat(cli): soften success green and use act accent in markdown

Swap the success/diff green from ANSI brightGreen and #22c55e to a muted
sage #87af87 on dark themes (auto-approve banner, git diff +stats, diff
view added-sign color); light themes keep the darker #116329 for contrast
on white. Markdown prominent elements (headings, bold, list markers,
links, table headers) now use the act accent via themePalette instead of
hardcoded one-dark cyan #56b6c2.

* feat(cli): harmonize dark syntax colors with brand accent palette

Rebuild the dark syntax highlighting family around the brand anchors:
functions use the act blue, strings and inline code use the success sage,
types/italics use a dimmed plan yellow, and the remaining hues (keyword
purple, variable coral, number orange, operator ice-blue) are regenerated
in OKLCH at the same pastel lightness/chroma weight (~L 0.78, C 0.11) so
code blocks read as part of the same palette. Light theme keeps its
GitHub-light set.

* fix(cli): brighten success green to match accent palette weight

#87af87 sat at roughly half the OKLCH chroma of the act/plan anchors and
read as gray next to them; #8bd28d (L 0.80, C 0.12) matches their weight.

* fix(cli): brighten success green a step further (#99e89b)

* feat(cli): polish status bar usage display and ClinePass model name

- Cost always renders with two decimals ($0.00) instead of switching to
  four decimals under a cent; the turn summary line drops its three-decimal
  format for the same reason.
- Token count next to the context bar is now just the number; the word
  'tokens' was redundant with the bar right beside it.
- Context window bar shrinks from 8 to 6 cells.
- ClinePass models resolve their friendly models.dev name like every other
  provider and get a (ClinePass) suffix: 'GLM 5.2 (ClinePass)' instead of
  'ClinePass/glm-5.2'.
- ClinePass no longer shows '$0.00 (included with subscription)' -- cost is
  simply hidden for subscription providers.

* fix(cli): place ClinePass suffix after reasoning effort in model name

* fix(cli): format ClinePass model name as 'ClinePass: <model>' prefix

* feat(cli): color transcript entries by the mode they were produced in (#12083)

* feat(cli): color transcript entries by the mode they were produced in

Previously the whole transcript retinted to the current mode's accent on
every plan/act toggle. Entries now record the agent mode active when
they were produced and keep that accent permanently, so a session reads
as a visible history of plan (yellow) and act (blue) segments.

How the mode is captured:
- Live sessions: appendEntry in SessionProvider stamps entries from a
  uiMode ref, covering every creation site including mid-run
  switch_to_act_mode flips (which already call setUiMode through the
  runtime dialog bridge).
- Resumed sessions: hydrateSessionMessages recovers the mode from the
  persisted <user_input mode="..."> wrappers via a new shared
  parseUserInputMode helper, and flips to act at switch_to_act_mode tool
  calls. Transcripts without wrappers stay unstamped and keep the
  current-mode fallback accent, matching the old behavior.
- Restores: the /history resume and checkpoint-restore paths insert
  hydrated history via replaceEntries instead of appendEntry loops, so
  live-entry stamping cannot overwrite hydration's stamps (which would
  lock resumed transcripts to the resume-time accent).

The load-bearing core fix: readPersistedMessagesFile stripped the
user_input wrappers and mode notices from user text on every read
('display sanitization'). That read path also feeds session restarts
(mode toggle, compaction-mode change, model change, fork, recovery),
which re-persist what they read -- so every restart laundered the mode
markers off disk and out of the model's seeded context, leaving nothing
for hydration to recover. Reads now return persisted messages verbatim
and formatting is the display surface's job: the CLI TUI, history
titles, and the VS Code SDK history loader already formatted at their
boundaries; the cline-hub webview history mapping and the CLI HTML
export (which used normalizeUserInput and leaked mode_notice text) now
do too. Connectors only surface assistant text, and the remaining
readMessages consumers are programmatic (usage math, re-seeding,
compaction input) where raw is correct.

* fix(shared): match parseUserInputMode exactly to what the writer emits

Drop the case-insensitive flag and the 'zen' value from the wrapper
regex: formatUserInputBlock only ever writes lowercase act/plan/yolo, so
anything else the parser accepted (uppercase look-alikes in adversarial
content, a zen value with no writer) could never be real persisted data.
2026-07-06 16:00:17 -07:00
Saoud Rizwan 53d1567731 feat(cli): default thinking level picker cursor to Medium instead of Off (#12092)
* feat(cli): default thinking level picker cursor to Medium instead of Off

* chore(cli): drop explanatory comments from thinking level defaults
2026-07-06 15:51:40 -07:00
Saoud Rizwan 9a93008463 feat(cli): restyle chat input with horizontal rules and slim user bubbles (#12074)
* feat(cli): restyle chat input with horizontal rules and slim user bubbles

Replace the tinted-background input field with a minimal frame: full-width
horizontal rules above and below the textarea and a bold accent-colored
prompt glyph, with no background fill. User message bubbles in the
transcript match the new look: a slim neutral-gray bar with the same
glyph, no vertical padding, and no mode-colored tint.

Palette gains getInputRuleColor (neutral adaptive mid-gray for the rules)
and getUserMessageBackground (neutral bubble tint), both built on a shared
OKLAB lift helper extracted from getModeInputBackground. Home view's
robot cursor-tracking offset is adjusted for the input's new left edge,
and the spacer line between the chat input and status bar is removed.

* feat(cli): replace cyan accent with new plan/act palette (#12075)

* feat(cli): replace cyan accent with new plan/act palette

Swap the TUI accent colors: act mode goes from ANSI cyan to #79b8ff and
plan mode from ANSI yellow to #ffea7f on dark themes. Light themes get
counterparts derived in OKLCH with the same hue but darkened to hold
>=4.5:1 contrast on white (#0f72cb act, #867100 plan), selected via the
existing getModeAccent theme switch.

All hardcoded "cyan" fg literals across dialogs, model selector, config
view, and onboarding now reference palette.act so the accent is a single
source of truth. The selection highlight follows the act color. The
subliminal OKLAB chroma nudge on input backgrounds/foregrounds now leans
blue (-a, -b) instead of cyan (-a, +b) to match the new accent hue.

* feat(cli): soften success green and use act accent in markdown

Swap the success/diff green from ANSI brightGreen and #22c55e to a muted
sage #87af87 on dark themes (auto-approve banner, git diff +stats, diff
view added-sign color); light themes keep the darker #116329 for contrast
on white. Markdown prominent elements (headings, bold, list markers,
links, table headers) now use the act accent via themePalette instead of
hardcoded one-dark cyan #56b6c2.

* feat(cli): harmonize dark syntax colors with brand accent palette

Rebuild the dark syntax highlighting family around the brand anchors:
functions use the act blue, strings and inline code use the success sage,
types/italics use a dimmed plan yellow, and the remaining hues (keyword
purple, variable coral, number orange, operator ice-blue) are regenerated
in OKLCH at the same pastel lightness/chroma weight (~L 0.78, C 0.11) so
code blocks read as part of the same palette. Light theme keeps its
GitHub-light set.

* fix(cli): brighten success green to match accent palette weight

#87af87 sat at roughly half the OKLCH chroma of the act/plan anchors and
read as gray next to them; #8bd28d (L 0.80, C 0.12) matches their weight.

* fix(cli): brighten success green a step further (#99e89b)
2026-07-06 15:50:50 -07:00
Saoud Rizwan 678b0ae951 docs: polish README model table grammar and wording (#12098)
Claude-Session: https://claude.ai/code/session_017VJNCE1o6zzcVfpjpTVnt5

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-06 15:29:12 -07:00
cline-cloud[bot] 8b6f2cf0b7 Raise live catalog default input tokens (#11930)
* fix(llms): raise catalog default input tokens

* Lower live catalog default input tokens to 128000

* Lower compaction DEFAULT_MAX_INPUT_TOKENS to 128000

---------

Co-authored-by: Cline Bot <bot@cline.bot>
Co-authored-by: John Simone <john@cline.bot>
2026-07-06 10:48:51 -07:00
543 changed files with 46910 additions and 11606 deletions
+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"
+214 -47
View File
@@ -1,14 +1,40 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -17,7 +43,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
if: github.repository == 'cline/cline'
permissions:
contents: read
pull-requests: read
@@ -27,60 +53,79 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Show build source
working-directory: ${{ github.workspace }}
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
run: |
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
working-directory: next-src
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -90,20 +135,24 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Publish Nightly Extension
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -111,12 +160,129 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
- name: Tag published commit
working-directory: ${{ github.workspace }}
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -124,10 +290,11 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.103.0
VSCODE_TEST_VERSION: 1.101.0
strategy:
fail-fast: false
matrix:
+58
View File
@@ -260,6 +260,41 @@ jobs:
git push origin "refs/tags/${TAG}"
done
- name: Get Previous SDK Tag
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: prev_tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# The checkout is shallow and tagless, so fetch the release tags explicitly.
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: changelog
run: |
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
DELIMITER=$(openssl rand -hex 8)
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: softprops/action-gh-release@v1
with:
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
name: "SDK v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
@@ -280,3 +315,26 @@ jobs:
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
- name: Post release to Slack
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline SDK v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline SDK v${{ steps.version.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
+7
View File
@@ -85,3 +85,10 @@ apps/vscode/webview-ui/src/**/*.js.map
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
-8
View File
@@ -39,14 +39,6 @@
"sdk/packages/core/src/auth/**"
],
"severity": "high"
},
{
"id": "sdk-telemetry-doc-update",
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
"scope": [
"sdk/packages/core/src/services/telemetry/core-events.ts"
],
"severity": "medium"
}
]
}
+1 -5
View File
@@ -16,13 +16,9 @@
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
},
{
"path": "DOC.md",
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
},
{
"path": "sdk/ARCHITECTURE.md",
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
},
{
"path": "sdk/AGENTS.md",
+22 -17
View File
@@ -36,8 +36,13 @@ event names. It exports:
1. Add the constant to `CORE_TELEMETRY_EVENTS`
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
3. Update the Event Catalog section in `DOC.md`
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
**All events should be named using snake_case and so should their properties**
## The Activation Funnel
@@ -82,7 +87,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
config dir.
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
The canonical pattern is in `apps/cli/src/main.ts`:
```ts
if (configDir) setClineDir(configDir);
@@ -90,18 +95,18 @@ setHomeDir(homedir());
captureCliExtensionActivated(); // <-- after dir overrides
```
## Hub Daemon Metadata Forwarding
## Hub Daemon Telemetry
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
metadata into the daemon argv so the daemon can reconstruct an equivalent
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
identifies from the cached cline account (re-resolved periodically, since the daemon often
starts before login) and flushes on every shutdown path, including startup failure.
```
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
```
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
## Auth Lifecycle Completeness
@@ -120,10 +125,10 @@ canonical examples of all four phases.
## Single Telemetry Service Per Host
On VS Code, the telemetry handle is built **once** in `activate()`
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
command, and daemon spawn payload. Do not let individual controllers construct their own
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
On VS Code, all callers go through the lazy `telemetryService` proxy in
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
use. Do not let individual controllers construct their own `ITelemetryService` — that
fragments distinct-id state, opt-out tracking, and flush ownership.
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
+7 -7
View File
@@ -68,7 +68,7 @@
"command": "bun run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
"isBackground": false,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
@@ -89,7 +89,7 @@
"command": "bun run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
"isBackground": false,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
@@ -114,16 +114,16 @@
{
"pattern": [
{
"regexp": ".",
"regexp": "^(?!)((?:.*))$",
"kind": "file",
"file": 1,
"location": 2,
"message": 3
"message": 1
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "."
"beginsPattern": "^Building webview for|^\\s*VITE",
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
}
}
],
+4 -4
View File
@@ -149,7 +149,7 @@ Toggle between Plan mode and Act mode. In Plan mode, Cline explores your codebas
## Rules and Skills
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
## Works With Every Model
@@ -158,10 +158,10 @@ Cline is not locked to a single AI provider. Use whichever model fits your workf
| Provider | Models |
|----------|--------|
| Anthropic | Claude Opus, Sonnet, Haiku |
| OpenAI | GPT series model |
| Google | Gemini series model |
| OpenAI | GPT series models |
| Google | Gemini series models |
| OpenRouter | 200+ models from any provider |
| Vercel AI Gateway | Models through Vercel AI Gateway |
| Vercel AI Gateway | Route to many providers through one gateway |
| AWS Bedrock | Claude, Llama, and more |
| Azure / GCP Vertex | All hosted models |
| Cerebras / Groq | Fast inference models |
+45
View File
@@ -1,5 +1,50 @@
# Cline CLI Changelog
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
- Fixed provider config not reloading when switching models
- Fixed auto-update failing to detect Bun global installs after symlink resolution
- Fixed unexpected logouts caused by transient network or server errors during token refresh
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
- Session id is now preserved when continuing within the same session
- Hardened context compaction budget handling
## 3.0.39
- You can now select Cline free models on the ClinePass provider in the model picker
- Removed the retired ClinePass GLM 5.1 model
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
- `str_replace` edits now report accurate diffs
- Fixed context compaction so canonical session history is preserved
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
- Cline provider requests now send versioned client-identity headers
## 3.0.38
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
- Polished the status bar usage display and ClinePass model name
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
- The thinking-level picker now defaults its cursor to Medium instead of Off
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
## 3.0.37
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.37",
"version": "3.0.42",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+19 -1
View File
@@ -511,6 +511,7 @@ export class AcpAgent implements Agent {
private async buildConfig(session: SessionState): Promise<Config> {
const cwd = session.cwd || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
// Resolve credentials: env vars take precedence, then session provider.
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
@@ -519,6 +520,7 @@ export class AcpAgent implements Agent {
providerId,
mode: session.currentMode,
});
const cliBuildInfo = getCliBuildInfo();
return {
providerId,
@@ -537,7 +539,23 @@ export class AcpAgent implements Agent {
enableAgentTeams: false,
enableTools: true,
cwd,
workspaceRoot: resolveWorkspaceRoot(cwd),
workspaceRoot,
extensionContext: {
client: {
name: "cline-acp",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
workspace: {
rootPath: workspaceRoot,
cwd,
workspaceName: cwd,
ide: "Terminal Shell",
platform: process.platform,
},
},
};
}
}
+16
View File
@@ -101,6 +101,22 @@ describe("getInstallationInfo", () => {
});
});
it("detects bun global installs from the resolved install path", () => {
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
// and realpathSync resolves through the symlink before detection runs.
const wrapperPath = createTempFile(
".bun/install/global/node_modules/cline/bin/cline",
);
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.BUN,
packageName: "cline",
updateCommand: "bun add -g cline@latest",
});
});
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
delete process.env.CLINE_WRAPPER_PATH;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
+6 -1
View File
@@ -118,7 +118,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
};
}
if (scriptPath.includes("/.bun/bin")) {
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
// them to ~/.bun/install/global/node_modules/..., so match both.
if (
scriptPath.includes("/.bun/bin") ||
scriptPath.includes("/.bun/install/global/")
) {
return {
packageManager: PackageManager.BUN,
packageName: DEFAULT_PACKAGE_NAME,
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
it("uses auth material resolved by provider settings manager", async () => {
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
+76 -1
View File
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
});
vi.mock("./utils/session", () => sessionMocks);
vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", () => {
vi.mock("@cline/core", async () => {
return {
...(await vi.importActual("@cline/core")),
resolveProviderConfig: llmMocks.resolveProviderConfig,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
@@ -1013,6 +1014,80 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
// CLINE-2406: when persisted Cline auth includes an accountId, the
// runtime path must call identifyTelemetryAccount(accountContext) so
// subsequent task.* and workspace.* events carry user_id.
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
};
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
clineSettings,
);
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "cline",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
expect.objectContaining({
id: "usr-abc-123",
provider: "cline",
}),
);
});
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
// identifyTelemetryAccount should not be called from the runtime path.
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
// no auth / no accountId
};
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
clineSettings,
);
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "cline",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
});
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
// CLINE-2406: identity identification from saved settings only applies
// to Cline-provider sessions; other providers use different auth flows.
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
provider: "openrouter",
model: "openai/gpt-5",
});
providerSettingsMocks.getProviderSettings.mockReturnValue({
provider: "openrouter",
model: "openai/gpt-5",
});
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "openrouter",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
+40 -1
View File
@@ -15,6 +15,7 @@ import {
getPreferredKanbanInstaller,
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
@@ -46,6 +47,7 @@ import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
getCliTelemetryService,
identifyTelemetryAccount,
} from "./utils/telemetry";
import type { Config } from "./utils/types";
import { runConnectWizard } from "./wizards/connect";
@@ -926,6 +928,17 @@ export async function runCli(): Promise<void> {
runAgent,
} = await loadCliRuntimeModules();
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
// These components operate before/outside ClineCore sessions, so the
// session-scoped logger can't reach them.
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
component: "main",
});
coreServer.setSdkLogger(loggerAdapter.core);
const userInstructionService = createUserInstructionConfigService({
skills: {
workspacePath: workspaceRoot,
@@ -962,6 +975,25 @@ export async function runCli(): Promise<void> {
);
let selectedProviderSettings =
providerSettingsManager.getProviderSettings(provider);
// Apply locally persisted Cline account identity so subsequent events
// (task.*, workspace.initialized) carry user_id when available.
// Note: user.extension_activated fires anonymously earlier in startup
// and cannot be retroactively updated; this is by design for
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
if (provider === "cline") {
const savedAuth = selectedProviderSettings?.auth;
if (savedAuth?.accountId) {
identifyTelemetryAccount({
id: savedAuth.accountId,
provider: "cline",
organizationId: savedAuth.organizationId,
organizationName: savedAuth.organizationName,
memberId: savedAuth.memberId,
});
}
}
const persistedApiKey = getPersistedProviderApiKey(
provider,
selectedProviderSettings,
@@ -1029,6 +1061,7 @@ export async function runCli(): Promise<void> {
reasoningEffort: args.reasoningEffort,
persistedReasoning: selectedProviderSettings?.reasoning,
});
const cliBuildInfo = getCliBuildInfo();
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -1079,7 +1112,13 @@ export async function runCli(): Promise<void> {
cwd,
workspaceRoot,
extensionContext: {
client: { name: "cline-cli" },
client: {
name: "cline-cli",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
workspace: {
rootPath: workspaceRoot,
cwd,
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.maxInputTokens).toBe(400_000);
expect(context.budget.request.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -126,10 +126,11 @@ describe("compactInteractiveMessages", () => {
expect(compact).toHaveBeenCalledTimes(1);
expect(result.compacted).toBe(true);
expect(result.messages).toEqual([messages[0]]);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("falls back to legacy contextWindow for manual compaction", async () => {
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -137,7 +138,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.maxInputTokens).toBe(400_000);
expect(context.budget.request.maxInputTokens).toBe(360_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -157,7 +158,8 @@ describe("compactInteractiveMessages", () => {
expect(compact).toHaveBeenCalledTimes(1);
expect(result.compacted).toBe(true);
expect(result.messages).toEqual([messages[0]]);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses a useful target budget for manual compaction", async () => {
@@ -174,7 +176,8 @@ describe("compactInteractiveMessages", () => {
messages,
});
const compactedTextLength = result.messages.reduce(
const compactedMessages = result.compactionState?.messages ?? [];
const compactedTextLength = compactedMessages.reduce(
(total, message) =>
total +
(typeof message.content === "string" ? message.content.length : 0),
@@ -182,8 +185,9 @@ describe("compactInteractiveMessages", () => {
);
expect(result.compacted).toBe(true);
expect(result.messages.length).toBeGreaterThan(1);
expect(result.messages.length).toBeLessThan(messages.length);
expect(result.canonicalMessages).toEqual(messages);
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
});
@@ -214,8 +218,9 @@ describe("compactInteractiveMessages", () => {
});
expect(result.compacted).toBe(true);
expect(result.messages).toHaveLength(messages.length);
expect(result.messages[0]?.content).toBe(
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toHaveLength(messages.length);
expect(result.compactionState?.messages[0]?.content).toBe(
"same count but content should be trimmed",
);
});
+35 -16
View File
@@ -1,9 +1,11 @@
import {
createContextCompactionPrepareTurn,
createSessionCompactionState,
type ProviderConfig,
type ProviderSettings,
type ProviderSettingsManager,
type ReasoningSettings,
type SessionCompactionState,
toProviderConfig,
} from "@cline/core";
import type { Message } from "@cline/shared";
@@ -52,13 +54,22 @@ export async function compactInteractiveMessages(input: {
providerSettingsManager: ProviderSettingsManager;
sessionId: string;
messages: Message[];
}): Promise<{ compacted: boolean; messages: Message[] }> {
abortSignal?: AbortSignal;
}): Promise<{
compacted: boolean;
canonicalMessages: Message[];
compactionState?: SessionCompactionState;
}> {
const modelInfo = input.config.knownModels?.[input.config.modelId];
const maxInputTokens =
input.config.compaction?.maxInputTokens ??
modelInfo?.maxInputTokens ??
modelInfo?.contextWindow ??
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
const compactionModelInfo = modelInfo
? {
...modelInfo,
id: modelInfo.id ?? input.config.modelId,
}
: {
id: input.config.modelId,
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
};
const compact = createContextCompactionPrepareTurn(
{
providerConfig: resolveCompactionProviderConfig(
@@ -81,8 +92,11 @@ export async function compactInteractiveMessages(input: {
{ mode: "manual" },
);
if (!compact) {
return { compacted: false, messages: input.messages };
return { compacted: false, canonicalMessages: input.messages };
}
// Manual compaction intentionally summarizes the full canonical transcript
// instead of reusing a prior sidecar summary, which avoids summary-of-summary
// drift across repeated `/compact` calls.
const result = await compact({
agentId: "cli",
conversationId: input.sessionId,
@@ -90,21 +104,26 @@ export async function compactInteractiveMessages(input: {
iteration: 0,
messages: input.messages,
apiMessages: input.messages,
abortSignal: new AbortController().signal,
abortSignal: input.abortSignal ?? new AbortController().signal,
systemPrompt: "",
tools: [],
model: {
id: input.config.modelId,
provider: input.config.providerId,
info: {
...(modelInfo ?? {}),
id: modelInfo?.id ?? input.config.modelId,
maxInputTokens: maxInputTokens,
},
info: compactionModelInfo,
},
});
if (!result) {
return { compacted: false, messages: input.messages };
if (!result?.messages) {
return { compacted: false, canonicalMessages: input.messages };
}
return { compacted: true, messages: result.messages };
return {
compacted: true,
canonicalMessages: input.messages,
compactionState: createSessionCompactionState({
sourceMessages: input.messages,
compactedMessages: result.messages,
conversationId: input.sessionId,
systemPrompt: result.systemPrompt,
}),
};
}
+7 -32
View File
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
};
}
export type ModeSwitchNotice = {
from: InteractiveUiMode;
to: InteractiveUiMode;
};
/**
* Tracks a user-initiated mode switch so the next user message can carry a
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
* switch_to_act_mode path already announces itself via the continuation
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
* out, since the mode the model last saw never effectively changed.
*/
export function createModeSwitchNoticeTracker() {
let pending: ModeSwitchNotice | null = null;
return {
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
if (from === to) {
return;
}
if (pending) {
pending = pending.from === to ? null : { from: pending.from, to };
return;
}
pending = { from, to };
},
consume(): ModeSwitchNotice | null {
const notice = pending;
pending = null;
return notice;
},
};
}
// The tracker moved to @cline/shared so the VSCode extension can share the
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
// import surface stable.
export {
createModeSwitchNoticeTracker,
type ModeSwitchNotice,
} from "@cline/shared";
export async function applyInteractiveModeConfig(input: {
config: Config;
@@ -1,81 +1,89 @@
import type {
AgentEvent,
ProviderSettingsManager,
TeamEvent,
ToolApprovalRequest,
ToolApprovalResult,
import {
createSessionCompactionState,
type ProviderSettingsManager,
type SessionManifest,
SessionNotFoundError,
SessionSource,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import { SessionNotFoundError } from "@cline/core";
import type { AgentTool, Message } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatCommandState } from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
const {
mockCreateCliCore,
mockCreateRuntimeHooks,
mockLoadInteractiveResumeMessages,
mockSetActiveCliSession,
} = vi.hoisted(() => ({
mockCreateCliCore: vi.fn(),
mockCreateRuntimeHooks: vi.fn(),
mockLoadInteractiveResumeMessages: vi.fn(),
mockSetActiveCliSession: vi.fn(),
}));
const createCliCoreMock = vi.hoisted(() => vi.fn());
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
vi.mock("../../session/session", () => ({
createCliCore: mockCreateCliCore,
}));
vi.mock("../../utils/hooks", () => ({
createRuntimeHooks: mockCreateRuntimeHooks,
}));
vi.mock("../../utils/output", () => ({
setActiveCliSession: mockSetActiveCliSession,
}));
vi.mock("../../utils/resume", () => ({
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
createCliCore: createCliCoreMock,
}));
vi.mock("../../utils/approval", () => ({
submitAndExitInTerminal: vi.fn(),
submitAndExitInTerminal: submitAndExitInTerminalMock,
}));
vi.mock("../../utils/hooks", () => ({
createRuntimeHooks: createRuntimeHooksMock,
}));
vi.mock("../../utils/output", () => ({
setActiveCliSession: setActiveCliSessionMock,
}));
vi.mock("../../utils/resume", () => ({
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
}));
vi.mock("../active-runtime", () => ({
markAbortInProgress: vi.fn(),
markAbortInProgress: markAbortInProgressMock,
}));
vi.mock("../session-events", () => ({
subscribeToAgentEvents: vi.fn(() => vi.fn()),
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
subscribeToAgentEvents: subscribeToAgentEventsMock,
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
}));
import { createInteractiveSessionRuntime } from "./session-runtime";
vi.mock("./compaction", () => ({
compactInteractiveMessages: compactInteractiveMessagesMock,
}));
function makeConfig(): Config {
vi.mock("./exit-summary", () => ({
createInteractiveExitSummary: createInteractiveExitSummaryMock,
}));
function createConfig(): Config {
return {
providerId: "anthropic",
modelId: "claude-test",
apiKey: "",
providerId: "cline",
modelId: "openai/gpt-5.3-codex",
verbose: false,
sandbox: false,
thinking: false,
outputMode: "text",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
systemPrompt: "system",
mode: "act",
systemPrompt: "",
enableTools: true,
enableSpawnAgent: true,
enableAgentTeams: false,
defaultToolAutoApprove: false,
toolPolicies: {},
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
enableAgentTeams: true,
verbose: false,
thinking: false,
outputMode: "text",
sandbox: false,
defaultToolAutoApprove: true,
toolPolicies: {
"*": { autoApprove: true },
},
};
}
function makeChatCommandState(config: Config): ChatCommandState {
function createChatCommandState(config = createConfig()): ChatCommandState {
return {
enableTools: config.enableTools,
autoApproveTools: config.defaultToolAutoApprove,
@@ -84,6 +92,35 @@ function makeChatCommandState(config: Config): ChatCommandState {
};
}
function createProviderSettingsManager(): ProviderSettingsManager {
return {
getProviderSettings: vi.fn().mockReturnValue(undefined),
} as unknown as ProviderSettingsManager;
}
function createManifest(sessionId: string): SessionManifest {
return {
version: 1,
session_id: sessionId,
source: SessionSource.CLI,
pid: 1,
started_at: "2026-01-01T00:00:00.000Z",
status: "running",
interactive: true,
provider: "anthropic",
model: "claude-test",
cwd: "/tmp/project",
workspace_root: "/tmp/project",
enable_tools: true,
enable_spawn: true,
enable_teams: true,
};
}
async function importRuntime() {
return await import("./session-runtime");
}
function makeSwitchToActModeTool(): AgentTool {
return {
name: "switch_to_act_mode",
@@ -100,9 +137,9 @@ function makeManager() {
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: {
session_id: sessionId,
},
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
});
return {
@@ -114,10 +151,13 @@ function makeManager() {
dispose: vi.fn(),
get: vi.fn(),
readMessages: vi.fn(async (): Promise<Message[]> => []),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
readTranscript: vi.fn(),
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(async () => {}),
pendingPrompts: {
update: vi.fn(),
},
@@ -133,7 +173,7 @@ function makeTurnResult() {
toolCalls: [],
iterations: 1,
finishReason: "completed" as const,
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
model: { id: "claude-test", provider: "anthropic" },
startedAt: new Date("2026-01-01T00:00:00.000Z"),
endedAt: new Date("2026-01-01T00:00:00.100Z"),
durationMs: 100,
@@ -150,20 +190,22 @@ function deferred<T>() {
return { promise, resolve, reject };
}
function makeRuntime(
async function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: {
config?: Config;
resumeSessionId?: string;
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
} = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
createCliCoreMock.mockResolvedValue(manager);
const config = options.config ?? createConfig();
const { createInteractiveSessionRuntime } = await importRuntime();
return createInteractiveSessionRuntime({
config,
providerSettingsManager: {} as ProviderSettingsManager,
providerSettingsManager: createProviderSettingsManager(),
resumeSessionId: options.resumeSessionId,
chatCommandState: makeChatCommandState(config),
chatCommandState: createChatCommandState(config),
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
@@ -172,26 +214,325 @@ function makeRuntime(
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
onAgentEvent: (_event: AgentEvent) => {},
onTeamEvent: (_event: TeamEvent) => {},
onPendingPrompts: () => {},
onPendingPromptSubmitted: () => {},
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
}
describe("createInteractiveSessionRuntime", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateRuntimeHooks.mockReturnValue({
createCliCoreMock.mockReset();
compactInteractiveMessagesMock.mockReset();
createRuntimeHooksMock.mockReset();
setActiveCliSessionMock.mockReset();
loadInteractiveResumeMessagesMock.mockReset();
subscribeToAgentEventsMock.mockReset();
subscribeToPendingPromptEventsMock.mockReset();
markAbortInProgressMock.mockReset();
submitAndExitInTerminalMock.mockReset();
createInteractiveExitSummaryMock.mockReset();
createRuntimeHooksMock.mockReturnValue({
hooks: undefined,
shutdown: vi.fn(async () => {}),
shutdown: vi.fn().mockResolvedValue(undefined),
});
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
subscribeToAgentEventsMock.mockReturnValue(() => {});
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
});
it("manual compact updates the active session sidecar without restarting", async () => {
const sessionId = "sess-active";
const messages = [
{ id: "u1", role: "user" as const, content: "hello" },
{ id: "a1", role: "assistant" as const, content: "world" },
];
const compactionState = createSessionCompactionState({
sourceMessages: messages,
compactedMessages: [
{ id: "summary", role: "user" as const, content: "summary" },
],
updatedAt: "2026-01-01T00:00:00.000Z",
});
const manager = {
start: vi.fn().mockResolvedValue({
sessionId,
manifest: createManifest(sessionId),
manifestPath: "/tmp/session.json",
messagesPath: "/tmp/session.messages.json",
}),
readMessages: vi.fn().mockResolvedValue(messages),
updateSessionCompactionState: vi
.fn()
.mockResolvedValue({ updated: true }),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
compactInteractiveMessagesMock.mockResolvedValue({
compacted: true,
canonicalMessages: messages,
compactionState,
});
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
const result = await runtime.compactCurrentSession();
expect(result).toEqual({
messagesBefore: messages.length,
messagesAfter: messages.length,
workingContextMessagesAfter: compactionState.messages.length,
compacted: true,
});
expect(manager.start).toHaveBeenCalledTimes(1);
expect(manager.stop).not.toHaveBeenCalled();
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
config: expect.objectContaining({
providerId: "anthropic",
modelId: "claude-test",
}),
providerSettingsManager: expect.objectContaining({
getProviderSettings: expect.any(Function),
}),
sessionId,
messages,
abortSignal: expect.any(AbortSignal),
});
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
sessionId,
compactionState,
);
expect(runtime.getActiveSessionId()).toBe(sessionId);
});
it("rejects manual compact while the active session is running", async () => {
const sessionId = "sess-running";
const messages = [{ role: "user" as const, content: "hello" }];
const manager = {
start: vi.fn().mockResolvedValue({
sessionId,
manifest: createManifest(sessionId),
manifestPath: "/tmp/session.json",
messagesPath: "/tmp/session.messages.json",
}),
readMessages: vi.fn().mockResolvedValue(messages),
updateSessionCompactionState: vi
.fn()
.mockResolvedValue({ updated: true }),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({
sessionId,
status: "running",
}),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await expect(runtime.compactCurrentSession()).rejects.toThrow(
"Cannot compact while the current turn is running",
);
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
});
it("rejects manual compact when compaction is disabled", async () => {
const manager = makeManager();
const config = createConfig();
config.compaction = { enabled: false };
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
await expect(runtime.compactCurrentSession()).rejects.toThrow(
"compaction is off",
);
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
});
it("carries compacted working context across mode-switch restarts", async () => {
const firstSessionId = "sess-mode-before";
const secondSessionId = "sess-mode-after";
const prefixMessage = {
id: "u1",
role: "user" as const,
content: "large original",
};
const tailMessage = {
id: "u2",
role: "user" as const,
content: "new canonical tail",
};
const messages = [prefixMessage, tailMessage];
const summaryMessage = {
id: "summary",
role: "user" as const,
content: "summary",
};
const compactionState = createSessionCompactionState({
sourceMessages: [prefixMessage],
compactedMessages: [summaryMessage],
conversationId: firstSessionId,
systemPrompt: "compacted system",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const manager = {
start: vi
.fn()
.mockResolvedValueOnce({
sessionId: firstSessionId,
manifest: createManifest(firstSessionId),
manifestPath: "/tmp/session-before.json",
messagesPath: "/tmp/session-before.messages.json",
})
.mockResolvedValueOnce({
sessionId: secondSessionId,
manifest: createManifest(secondSessionId),
manifestPath: "/tmp/session-after.json",
messagesPath: "/tmp/session-after.messages.json",
}),
readMessages: vi.fn().mockResolvedValue(messages),
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
updateSessionCompactionState: vi
.fn()
.mockResolvedValue({ updated: true }),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await runtime.applyMode("plan");
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
firstSessionId,
);
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
const restartInput = manager.start.mock.calls[1]?.[0];
expect(restartInput).toMatchObject({
initialMessages: messages,
initialCompactionState: expect.objectContaining({
source_message_count: messages.length,
messages: [summaryMessage, tailMessage],
system_prompt: "compacted system",
}),
});
expect(restartInput.initialCompactionState).not.toHaveProperty(
"conversation_id",
);
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
});
it("defers creating the replacement session after a new-session reset", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
let startCount = 0;
const manager = {
start: vi.fn().mockImplementation(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
}),
readMessages: vi.fn().mockResolvedValue([]),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledOnce();
@@ -202,7 +543,7 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.stop).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("");
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
await runtime.ensureReady();
@@ -212,7 +553,7 @@ describe("createInteractiveSessionRuntime", () => {
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
expect(runtime.getActiveSessionId()).toBe("session-1");
@@ -224,7 +565,9 @@ describe("createInteractiveSessionRuntime", () => {
await gate.promise;
return {
sessionId: "session-restarted",
manifest: { session_id: "session-restarted" },
manifest: createManifest("session-restarted"),
manifestPath: "/tmp/session-restarted.json",
messagesPath: "/tmp/session-restarted.messages.json",
};
});
@@ -249,13 +592,13 @@ describe("createInteractiveSessionRuntime", () => {
const upstreamBeforeTool = vi.fn(async () => ({
input: { text: "updated" },
}));
mockCreateRuntimeHooks.mockReturnValueOnce({
createRuntimeHooksMock.mockReturnValueOnce({
hooks: {
beforeTool: upstreamBeforeTool,
},
shutdown: vi.fn(async () => {}),
});
const runtime = makeRuntime(manager, {
const runtime = await makeRuntime(manager, {
resolveToolPolicy: (toolName) => ({
autoApprove: toolName === "echo",
}),
@@ -307,14 +650,51 @@ describe("createInteractiveSessionRuntime", () => {
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
let startCount = 0;
const manager = {
start: vi.fn().mockImplementation(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
}),
readMessages: vi.fn().mockResolvedValue([]),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
resumeSessionId: "resumed-session",
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
1,
manager,
"resumed-session",
@@ -322,16 +702,14 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "resumed-session",
}),
config: expect.objectContaining({ sessionId: "resumed-session" }),
}),
);
await runtime.resetForNewSession();
await runtime.ensureReady();
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
2,
manager,
undefined,
@@ -348,8 +726,46 @@ describe("createInteractiveSessionRuntime", () => {
});
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
let startCount = 0;
const manager = {
start: vi.fn().mockImplementation(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
}),
readMessages: vi.fn().mockResolvedValue([]),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await runtime.restartEmpty();
@@ -371,7 +787,7 @@ describe("createInteractiveSessionRuntime", () => {
manager.send
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
.mockResolvedValueOnce(makeTurnResult());
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
const result = await runtime.sendCurrentTurn({
@@ -399,12 +815,89 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
const manager = makeManager();
const config = {
...createConfig(),
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
};
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "hello" }] },
];
manager.readMessages.mockResolvedValue(messages);
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
}),
}),
);
config.providerId = "openai-compatible";
config.modelId = "custom-model";
config.apiKey = "new-key";
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "session-1",
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
}),
initialMessages: messages,
}),
);
});
it("updates the active session connection in place without restarting", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.updateCurrentSessionConnection({
providerId: "openai",
modelId: "codex-test",
});
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
providerId: "openai",
modelId: "codex-test",
});
expect(manager.start).toHaveBeenCalledTimes(1);
expect(runtime.getActiveSessionId()).toBe("session-1");
});
it("does not reuse the session id when restarting empty", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartEmpty();
expect(manager.start).toHaveBeenCalledTimes(2);
const secondStart = manager.start.mock.calls[1]?.[0] as {
config?: { sessionId?: string };
};
expect(secondStart?.config?.sessionId).toBeUndefined();
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
new SessionNotFoundError("session-1"),
);
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
@@ -422,7 +915,7 @@ describe("createInteractiveSessionRuntime", () => {
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
const manager = makeManager();
let runtime!: ReturnType<typeof makeRuntime>;
let runtime!: Awaited<ReturnType<typeof makeRuntime>>;
manager.readMessages.mockImplementationOnce(async () => {
await runtime.restartEmpty();
return [
@@ -432,7 +925,7 @@ describe("createInteractiveSessionRuntime", () => {
},
];
});
runtime = makeRuntime(manager);
runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
@@ -450,7 +943,7 @@ describe("createInteractiveSessionRuntime", () => {
manager.get.mockResolvedValue(undefined);
manager.getAccumulatedUsage.mockResolvedValue(undefined);
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
const sendPromise = runtime
@@ -2,10 +2,13 @@ import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
createSessionCompactionState,
isSessionNotFoundError,
type PendingPromptMutationResult,
type ProviderSettingsManager,
projectSessionCompactionState,
readSessionCheckpointHistory,
type SessionCompactionState,
SessionSource,
type TeamEvent,
type ToolApprovalRequest,
@@ -46,6 +49,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
export type SessionConnectionUpdate = Parameters<
CliCore["updateSessionConnection"]
>[1];
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
@@ -116,6 +122,7 @@ export function createInteractiveSessionRuntime(input: {
// A reset can happen while an earlier manager.start() is still in flight.
// Bump this before resets and restarts so stale starts cannot become active.
let sessionStartGeneration = 0;
let manualCompactionAbortController: AbortController | undefined;
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
@@ -205,15 +212,23 @@ export function createInteractiveSessionRuntime(input: {
const startFreshSession = async (
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
// Restarting an old session associate with this ID,
// For continuing the same conversation, e.g. after a config change.
sessionId?: string,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const started = await manager.start({
source: SessionSource.CLI,
config: buildSessionConfig(),
config: {
...buildSessionConfig(),
...(sessionId ? { sessionId } : {}),
},
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
...(initialCompactionState ? { initialCompactionState } : {}),
...(sessionMetadata ? { sessionMetadata } : {}),
localRuntime: {
onTeamRestored: () => {},
@@ -309,6 +324,25 @@ export function createInteractiveSessionRuntime(input: {
}
};
const readCompactionState = async (
sessionId: string,
): Promise<SessionCompactionState | undefined> => {
const manager = sessionManager;
if (!manager) {
return undefined;
}
try {
return await manager.readSessionCompactionState(sessionId);
} catch (error) {
input.config.logger?.log?.("Failed to read session compaction state", {
sessionId,
error,
severity: "warn",
});
return undefined;
}
};
const recoverMissingActiveSession = async (
error: unknown,
): Promise<MissingSessionRecovery> => {
@@ -343,6 +377,15 @@ export function createInteractiveSessionRuntime(input: {
return await missingSessionRecoveryPromise;
};
const readCurrentCompactionState = async (): Promise<
SessionCompactionState | undefined
> => {
if (!activeSessionId) {
return undefined;
}
return await readCompactionState(activeSessionId);
};
const stopCurrentSession = async (): Promise<void> => {
const sessionId = activeSessionId;
if (sessionManager && sessionId) {
@@ -380,7 +423,15 @@ export function createInteractiveSessionRuntime(input: {
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
options?: { preserveSessionId?: boolean },
): Promise<void> => {
// Config-only restarts (model/mode/account changes) continue the same
// conversation, so they must keep the session id — otherwise each
// restart mints a new session history entry for the same conversation.
const reuseSessionId = options?.preserveSessionId
? activeSessionId || undefined
: undefined;
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
@@ -392,7 +443,12 @@ export function createInteractiveSessionRuntime(input: {
const restart = (async () => {
await stopCurrentSession();
clearActiveSession();
await startFreshSession(messages, sessionMetadata);
await startFreshSession(
messages,
sessionMetadata,
initialCompactionState,
reuseSessionId,
);
})().catch((error) => {
startupError = error;
throw error;
@@ -411,14 +467,45 @@ export function createInteractiveSessionRuntime(input: {
};
const restartWithCurrentMessages = async (): Promise<void> => {
const { messages, status } = await readCurrentMessages();
const [{ messages, status }, compactionState] = await Promise.all([
readCurrentMessages(),
readCurrentCompactionState(),
]);
if (status !== "read") {
// If reading recovered a missing hub session, the current messages are
// already in the replacement session. If the read is stale, another async
// operation changed the active session while this read was in flight.
return;
}
await restartWithMessages(messages);
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
await restartWithMessages(
messages,
undefined,
projectedMessages
? createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
})
: undefined,
{ preserveSessionId: true },
);
};
const updateCurrentSessionConnection = async (
update: SessionConnectionUpdate,
): Promise<void> => {
await ensureReady();
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
// No live session to update; the next startup builds its config from
// the already-mutated CLI config, so nothing else is needed.
return;
}
await manager.updateSessionConnection(sessionId, update);
};
const restartEmpty = async (): Promise<void> => {
@@ -532,6 +619,10 @@ export function createInteractiveSessionRuntime(input: {
if (messages.length === 0) {
throw new Error("Cannot fork an empty session.");
}
const compactionState = await readCompactionState(forkedFromSessionId);
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
await manager.stop(forkedFromSessionId);
const forkMetadata = buildForkSessionMetadata({
forkedFromSessionId,
@@ -539,8 +630,33 @@ export function createInteractiveSessionRuntime(input: {
sourceSession: sessionRecord,
messages,
});
await startFreshSession(messages, forkMetadata);
return { forkedFromSessionId, newSessionId: activeSessionId };
await startFreshSession(
messages,
forkMetadata,
projectedMessages
? createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
})
: undefined,
);
// Report carried context from what the new session actually accepted:
// the host can reject the inherited state (e.g. stale anchor), and the
// UI must not claim a carry-over that did not happen.
const acceptedState = projectedMessages
? await readCompactionState(activeSessionId)
: undefined;
return {
forkedFromSessionId,
newSessionId: activeSessionId,
carriedWorkingContext: acceptedState
? {
workingContextMessages: acceptedState.messages.length,
canonicalMessages: messages.length,
}
: undefined,
};
};
const resumeSession = async (sessionId: string): Promise<Message[]> => {
@@ -561,9 +677,17 @@ export function createInteractiveSessionRuntime(input: {
const compactCurrentSession = async (): Promise<{
messagesBefore: number;
messagesAfter: number;
workingContextMessagesAfter?: number;
compacted: boolean;
}> => {
if (!sessionManager) {
if (input.config.compaction?.enabled === false) {
throw new Error(
"Cannot compact because compaction is off for this session.",
);
}
const manager = sessionManager;
const sourceSessionId = activeSessionId;
if (!manager || !sourceSessionId) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const { messages, status } = await readCurrentMessages();
@@ -577,12 +701,28 @@ export function createInteractiveSessionRuntime(input: {
if (messagesBefore === 0) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const result = await compactInteractiveMessages({
config: input.config,
providerSettingsManager: input.providerSettingsManager,
sessionId: activeSessionId,
messages,
});
const sessionRecord = await manager.get(sourceSessionId);
if (sessionRecord?.status === "running") {
throw new Error(
"Cannot compact while the current turn is running. Wait for it to finish or abort it first.",
);
}
let result: Awaited<ReturnType<typeof compactInteractiveMessages>>;
const abortController = new AbortController();
manualCompactionAbortController = abortController;
try {
result = await compactInteractiveMessages({
config: input.config,
providerSettingsManager: input.providerSettingsManager,
sessionId: sourceSessionId,
messages,
abortSignal: abortController.signal,
});
} finally {
if (manualCompactionAbortController === abortController) {
manualCompactionAbortController = undefined;
}
}
if (!result.compacted) {
return {
messagesBefore,
@@ -590,10 +730,24 @@ export function createInteractiveSessionRuntime(input: {
compacted: false,
};
}
await restartWithMessages(result.messages);
if (!result.compactionState) {
return {
messagesBefore,
messagesAfter: messagesBefore,
compacted: false,
};
}
const updated = await manager.updateSessionCompactionState(
sourceSessionId,
result.compactionState,
);
if (!updated.updated) {
throw new Error("Compaction could not be saved. Try again.");
}
return {
messagesBefore,
messagesAfter: result.messages.length,
messagesAfter: result.canonicalMessages.length,
workingContextMessagesAfter: result.compactionState?.messages.length,
compacted: true,
};
};
@@ -683,6 +837,9 @@ export function createInteractiveSessionRuntime(input: {
}
abortRequested = true;
markAbortInProgress();
manualCompactionAbortController?.abort(
new Error("Interactive runtime abort requested"),
);
sessionManager
.abort(activeSessionId, new Error("Interactive runtime abort requested"))
.catch(() => {});
@@ -730,6 +887,7 @@ export function createInteractiveSessionRuntime(input: {
resetForNewSession,
restartWithMessages,
restartWithCurrentMessages,
updateCurrentSessionConnection,
resumeSession,
forkCurrentSession,
compactCurrentSession,
+4 -26
View File
@@ -9,23 +9,6 @@ import {
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
- Read files, search the codebase, and gather context to understand the problem
- Ask clarifying questions when requirements are ambiguous
- Present your plan as a structured outline with clear steps
- Explain tradeoffs between different approaches when they exist
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
export async function resolveSystemPrompt(input: {
cwd: string;
explicitSystemPrompt?: string;
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
mode?: AgentMode;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
// Both modes get the mode-tag explanation: after a switch, the transcript
// still contains messages tagged with the other mode.
rules = rules
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
: MODE_TAG_INSTRUCTIONS;
if (input.mode === "plan") {
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
}
// Mode-tag and plan-mode instructions are appended by the shared prompt
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
// @cline/shared), so only the caller-specific rules are merged here.
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
return buildClineSystemPrompt({
ide: "Terminal Shell",
workspaceRoot: input.cwd,
+153
View File
@@ -43,6 +43,15 @@ const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
"ClinePass limit reached",
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
].join("\n");
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
extractClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
const prefix = "you have reached your";
const suffix = "please try again later.";
const start = normalized.indexOf(prefix);
if (start === -1) return undefined;
const suffixStart = normalized.indexOf(suffix, start);
if (suffixStart === -1) return undefined;
const end = suffixStart + suffix.length;
if (!normalized.slice(start, end).includes("clinepass limit")) {
return undefined;
}
return text.slice(start, end);
},
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -769,6 +802,126 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLI_CLINE_PASS_LIMIT_MESSAGE,
);
});
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockImplementation(async () => {
sessionEventsMocks.listener?.({
type: "error",
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
recoverable: false,
});
return {
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
};
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+72 -2
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveReasoningForModelChange } from "./run-interactive";
import { describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
import {
applyInteractiveModelChange,
resolveReasoningForModelChange,
} from "./run-interactive";
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
).toEqual({ enabled: true, effort: "medium" });
});
});
describe("applyInteractiveModelChange", () => {
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
const config = {
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
thinking: undefined,
reasoningEffort: undefined,
} as Config;
const getProviderSettings = vi.fn(() => ({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible" as const,
protocol: "openai-chat" as const,
model: "old-model",
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
providers: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
const updateCurrentSessionConnection = vi.fn(async () => {});
await applyInteractiveModelChange({
config,
providerSettingsManager: {
getProviderSettings,
saveProviderSettings,
},
sessionRuntime: {
ensureReady,
restartWithCurrentMessages,
updateCurrentSessionConnection,
},
});
expect(saveProviderSettings).toHaveBeenCalledWith({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible",
protocol: "openai-chat",
model: "custom-model",
});
expect(ensureReady).toHaveBeenCalledOnce();
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
providerId: "openai-compatible",
modelId: "custom-model",
});
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
);
});
});
+50 -18
View File
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
return existing.reasoning;
}
export async function applyInteractiveModelChange(input: {
config: Config;
providerSettingsManager: Pick<
ProviderSettingsManager,
"getProviderSettings" | "saveProviderSettings"
>;
sessionRuntime: Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
| "ensureReady"
| "restartWithCurrentMessages"
| "updateCurrentSessionConnection"
>;
}): Promise<void> {
const { config, providerSettingsManager, sessionRuntime } = input;
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
// Provider changes affect more than the model connection: startup resolves
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
// the runtime with the existing transcript so all of that state changes
// together. restartWithCurrentMessages preserves the session ID.
await sessionRuntime.restartWithCurrentMessages();
// A same-ID restart reuses the existing manifest. Sync its connection label
// after the fully configured runtime is live so session history reflects the
// provider/model that will handle subsequent turns.
await sessionRuntime.updateCurrentSessionConnection({
providerId: config.providerId,
modelId: config.modelId,
});
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -687,25 +732,12 @@ export async function runInteractive(
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
onModelChange: () =>
applyInteractiveModelChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
await sessionRuntime.restartWithCurrentMessages();
},
providerSettingsManager,
sessionRuntime,
}),
onSessionRestart: async () => {
await sessionRuntime.ensureReady();
await sessionRuntime.restartEmpty();
+3 -3
View File
@@ -1,7 +1,7 @@
import {
type ContentBlock,
formatDisplayUserInput,
type MessageWithMetadata,
normalizeUserInput,
type ToolResultContent,
type ToolUseContent,
} from "@cline/shared";
@@ -681,7 +681,7 @@ function renderContentHTML(
toolResultsMap: Map<string, ToolResultContent>,
): string {
if (typeof content === "string") {
const text = isUser ? normalizeUserInput(content) : content;
const text = isUser ? formatDisplayUserInput(content) : content;
return renderTextHTML(text);
}
@@ -689,7 +689,7 @@ function renderContentHTML(
.map((block) => {
switch (block.type) {
case "text": {
const text = isUser ? normalizeUserInput(block.text) : block.text;
const text = isUser ? formatDisplayUserInput(block.text) : block.text;
return renderTextHTML(text);
}
case "tool_use":
+33
View File
@@ -151,6 +151,38 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -183,6 +215,7 @@ export async function loadClineAccountSnapshot(input: {
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
return {
user,
+110 -18
View File
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -18,12 +20,13 @@ import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getModeInputBackground,
getUserMessageBackground,
palette,
type TerminalTheme,
} from "../palette";
import type { ChatEntry } from "../types";
import { getSyntaxStyle } from "../utils/syntax-style";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
parseApplyPatchInput,
@@ -131,7 +134,7 @@ function formatToolParams(
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={f.path}>
<span key={`${i}:${f.path}`}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -291,7 +294,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
/>
<box flexDirection="row">
<text fg="gray">Purchase Credits: </text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
@@ -299,7 +302,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
</box>
<box flexDirection="row">
<text fg="gray">Purchase ClinePass: </text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
@@ -377,13 +380,13 @@ function ClinePassSubscriptionErrorView(props: {
)}
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>Open subscription page</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">URL: </text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
@@ -419,19 +422,98 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
);
}
function CompactionDividerRow(props: {
entry: Extract<ChatEntry, { kind: "compaction" }>;
}) {
const { entry } = props;
const { width: terminalWidth } = useTerminalDimensions();
const inProgress = entry.status === "started";
const labelColor = inProgress
? "cyan"
: entry.status === "failed"
? "red"
: entry.status === "cancelled" || entry.status === "skipped"
? "gray"
: "cyan";
const label = `${formatCompactionDividerLabel(entry)}`;
// Fill the remaining line with a plain rule instead of a flexGrow bordered
// box: a single fixed-content text row keeps the renderer's diffing stable.
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
return (
<box flexDirection="row">
{inProgress ? (
<box width={2}>
<spinner name="dots" color={labelColor} />
</box>
) : (
<text fg="gray" content="── " />
)}
<text fg={labelColor} selectable content={label} />
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
</box>
);
}
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
terminalTheme: TerminalTheme;
}) {
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">ClinePass limit reached</text>
<text fg={props.defaultFg} selectable content={detail} />
<text
fg={props.defaultFg}
selectable
content="Switch to Cline usage-based billing and retry with the Cline provider."
/>
<box flexDirection="row">
<text fg="gray">Interactive CLI: </text>
<text
fg={props.defaultFg}
selectable
content="type /model, press tab to change provider, choose Cline, then retry."
/>
</box>
<box flexDirection="row">
<text fg="gray">Headless CLI: </text>
<text fg={props.defaultFg} selectable content="rerun with " />
<code
content="--provider cline"
filetype="bash"
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
selectable
/>
<text fg={props.defaultFg} selectable content="." />
</box>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
/** Mode the entry was produced in (resolved with the current-mode fallback). */
mode?: SyntaxAccentMode;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
const { entry, accent = palette.act, terminalTheme } = props;
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const userMsgBg = getModeInputBackground(
accent === palette.plan ? "plan" : "act",
terminalBg,
);
const userMsgBg = getUserMessageBackground(terminalBg);
switch (entry.kind) {
case "user":
@@ -442,10 +524,9 @@ export function ChatEntryView(props: {
marginX={-1}
paddingLeft={1}
paddingRight={2}
paddingY={1}
>
<box width={2}>
<text fg={accent}>{">"}</text>
<text fg={accent}>{""}</text>
</box>
<text fg={defaultFg} selectable>
{entry.text}
@@ -461,10 +542,9 @@ export function ChatEntryView(props: {
marginX={-1}
paddingLeft={1}
paddingRight={2}
paddingY={1}
>
<box width={2}>
<text fg={accent}>{">"}</text>
<text fg={accent}>{""}</text>
</box>
{entry.delivery === "steer" && <text fg="yellow">[steer] </text>}
{entry.delivery === "queue" && <text fg="gray">[queued] </text>}
@@ -489,7 +569,7 @@ export function ChatEntryView(props: {
<box flexGrow={1}>
<markdown
content={content}
syntaxStyle={getSyntaxStyle(terminalTheme)}
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
streaming={entry.streaming}
fg={defaultFg}
/>
@@ -537,6 +617,15 @@ export function ChatEntryView(props: {
/>
);
}
if (isClinePassLimitErrorMessage(entry.text)) {
return (
<ClinePassLimitErrorView
message={entry.text}
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
);
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -560,12 +649,15 @@ export function ChatEntryView(props: {
</box>
);
case "compaction":
return <CompactionDividerRow entry={entry} />;
case "done": {
const parts: string[] = [];
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
if (entry.tokens > 0)
parts.push(`${entry.tokens.toLocaleString()} tokens`);
if (entry.cost > 0) parts.push(`$${entry.cost.toFixed(3)}`);
if (entry.cost > 0) parts.push(`$${entry.cost.toFixed(2)}`);
if (entry.iterations > 0)
parts.push(
`${entry.iterations} iteration${entry.iterations !== 1 ? "s" : ""}`,
@@ -96,11 +96,15 @@ export const ChatMessageList = forwardRef<
<box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
{props.entries.map((entry, i) => {
const key = `${i}:${entry.kind}`;
// Single source of truth for the entry's mode: the glyph accent
// and the markdown accent must never diverge.
const entryMode = entry.mode ?? props.uiMode ?? "act";
return (
<ChatEntryView
key={key}
entry={entry}
accent={accent}
accent={getModeAccent(entryMode, terminalTheme)}
mode={entryMode === "plan" ? "plan" : "act"}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
@@ -424,7 +424,7 @@ export function AccountDialogContent(
if (state.status === "loading") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">Cline Account</text>
<text fg={palette.act}>Cline Account</text>
<text fg="gray">{state.message}</text>
<text fg="gray">Esc to close</text>
</box>
@@ -434,7 +434,7 @@ export function AccountDialogContent(
if (state.status === "error") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">Cline Account</text>
<text fg={palette.act}>Cline Account</text>
<text fg="red">{state.message}</text>
<text fg="gray">Esc to close</text>
</box>
@@ -444,7 +444,7 @@ export function AccountDialogContent(
if (state.status === "unauthenticated") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">Cline Account</text>
<text fg={palette.act}>Cline Account</text>
<text>Sign in or create a Cline account.</text>
<text fg="gray">
Get access to the latest models with regular free promos and
@@ -473,7 +473,7 @@ export function AccountDialogContent(
if (view === "organizations") {
return (
<box flexDirection="column" paddingX={1}>
<text fg="cyan">Change Account</text>
<text fg={palette.act}>Change Account</text>
<box flexDirection="column" gap={0}>
{orgRows.map((row, index) => (
<OrganizationRow
@@ -503,7 +503,7 @@ export function AccountDialogContent(
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">Cline Account</text>
<text fg={palette.act}>Cline Account</text>
<box flexDirection="row" gap={2}>
<box
@@ -514,7 +514,7 @@ export function AccountDialogContent(
border
borderColor="gray"
>
<text fg="cyan">{userInitial(loaded)}</text>
<text fg={palette.act}>{userInitial(loaded)}</text>
</box>
<box flexDirection="column" flexGrow={1}>
<text selectable>{displayName}</text>
@@ -191,7 +191,7 @@ export function CommandPaletteContent(
{" "}
</text>
<text
fg={isSelected ? palette.textOnSelection : "cyan"}
fg={isSelected ? palette.textOnSelection : palette.act}
width={shortcutWidth}
flexShrink={0}
>
@@ -90,7 +90,7 @@ export function ExtDetailContent(
flexDirection="row"
justifyContent="space-between"
>
<text fg="cyan">
<text fg={palette.act}>
<strong>{row.name}</strong>
</text>
<text
@@ -1,6 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { palette } from "../../palette";
type HelpRow =
| { kind: "heading"; id: string; text: string }
@@ -277,7 +278,7 @@ export function HelpDialogContent(props: ChoiceContext<void>) {
}
return (
<box key={row.id} flexDirection="row" paddingX={1}>
<text fg="cyan" width={KEY_WIDTH} flexShrink={0}>
<text fg={palette.act} width={KEY_WIDTH} flexShrink={0}>
{row.key}
</text>
<text fg="gray">{row.desc}</text>
@@ -121,7 +121,7 @@ export function McpManagerContent(
return (
<box flexDirection="column" paddingX={1}>
<text fg="cyan">MCP Servers</text>
<text fg={palette.act}>MCP Servers</text>
<text fg="gray" marginTop={1}>
Settings file:
@@ -141,7 +141,7 @@ export function McpManagerContent(
const enabledIcon =
typeof srv.enabled === "boolean" ? (enabled ? "● " : "○ ") : "";
const status = getMcpManagerEntryStatus(srv);
let rowColor = isSel ? "cyan" : "gray";
let rowColor = isSel ? palette.act : "gray";
if (enabled && typeof srv.enabled === "boolean") {
rowColor = palette.success;
}
@@ -1,8 +1,50 @@
import {
getProviderAuthStorageId,
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
/**
* Persist a manually entered API key for an OAuth-capable provider — the
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
* stale token would otherwise keep winning over the manual key.
*
* The key is written both to the provider's auth storage entry (cline-pass
* stores credentials under "cline") and to the provider's own entry: settings
* resolution lets a direct entry shadow the storage entry, and provider
* switching copies merged settings (including auth) into direct entries, so
* both must be updated for the manual key to reliably take effect.
*/
export function saveManualProviderApiKey(
manager: ProviderSettingsManager,
providerId: string,
apiKey: string,
): void {
// Empty strings delete these keys from the stored auth object.
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
saveLocalProviderSettings(manager, {
providerId: storageProviderId,
apiKey,
auth: clearedAuth,
});
if (
providerId !== storageProviderId &&
manager.read().providers[providerId]
) {
saveLocalProviderSettings(manager, {
providerId,
apiKey,
auth: clearedAuth,
});
}
}
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProviderSettingsManager } from "@cline/core";
import { afterEach, describe, expect, it } from "vitest";
import {
getPersistedProviderApiKey,
isProviderConfigured,
} from "../../../utils/provider-auth";
import {
buildClinePassSubscriptionPageUrl,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
@@ -16,3 +27,99 @@ describe("buildClinePassSubscriptionPageUrl", () => {
);
});
});
describe("saveManualProviderApiKey", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
function createManager(): ProviderSettingsManager {
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
tempDirs.push(dir);
return new ProviderSettingsManager({
filePath: join(dir, "providers.json"),
});
}
it("clears stored OAuth tokens so the manual key takes effect", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
accountId: "acct_123",
},
});
saveManualProviderApiKey(manager, "cline", "manual-api-key");
const settings = manager.getProviderSettings("cline");
expect(settings?.apiKey).toBe("manual-api-key");
expect(settings?.auth?.accessToken).toBeUndefined();
expect(settings?.auth?.refreshToken).toBeUndefined();
expect(settings?.auth?.accountId).toBe("acct_123");
expect(getPersistedProviderApiKey("cline", settings)).toBe(
"manual-api-key",
);
expect(isProviderConfigured("cline", settings)).toBe(true);
});
it("saves cline-pass keys to the shared cline auth storage entry", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
// cline-pass inherits auth storage from the "cline" entry, so the key
// must land there and the stale tokens must be gone for both providers.
const clineSettings = manager.getProviderSettings("cline");
expect(clineSettings?.apiKey).toBe("manual-api-key");
expect(clineSettings?.auth?.accessToken).toBeUndefined();
const clinePassSettings = manager.getProviderSettings("cline-pass");
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
"manual-api-key",
);
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
});
it("clears stale credentials copied into a direct cline-pass entry", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
// Provider switching copies the merged settings (including auth) into
// a direct cline-pass entry, which shadows the shared "cline" entry.
manager.saveProviderSettings({
provider: "cline-pass",
apiKey: "stale-copied-key",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
const clinePassSettings = manager.getProviderSettings("cline-pass");
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
"manual-api-key",
);
});
});
@@ -37,7 +37,10 @@ import {
getSearchableListRowsWindow,
type SearchableItem,
} from "../searchable-list";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
import {
buildClinePassSubscriptionPageUrl,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
interface ProviderItem {
id: string;
@@ -371,14 +374,14 @@ function ClinePassBrowserPageContent(
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
<text>{status}</text>
<text fg="gray">{pageLabel}:</text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
<a href={url}>{url}</a>
</text>
@@ -596,7 +599,7 @@ export function ProviderConfigInputContent(
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
@@ -689,7 +692,7 @@ export function CodexCliStatusContent(
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
@@ -707,7 +710,7 @@ export function CodexCliStatusContent(
<text fg="yellow">Codex CLI was not found</text>
<text fg="gray">{status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
</box>
@@ -724,13 +727,27 @@ export function CodexCliStatusContent(
);
}
/**
* Resolves `true` on successful login, `"use_api_key"` when the user opts
* into manual API key entry (only offered with `allowApiKeyFallback`).
*/
export type OAuthLoginResult = boolean | "use_api_key";
export function OAuthLoginContent(
props: ChoiceContext<boolean> & {
props: ChoiceContext<OAuthLoginResult> & {
providerId: string;
providerName: string;
allowApiKeyFallback?: boolean;
},
) {
const { resolve, dismiss, dialogId, providerId, providerName } = props;
const {
resolve,
dismiss,
dialogId,
providerId,
providerName,
allowApiKeyFallback,
} = props;
const [mode, setMode] = useState<"browser" | "device">(
providerId === "cline" ? "device" : "browser",
);
@@ -863,13 +880,23 @@ export function OAuthLoginContent(
if (key.name === "escape") {
cancelAuthAttempt();
dismiss();
return;
}
if (key.name === "k" && allowApiKeyFallback) {
cancelAuthAttempt();
resolve("use_api_key");
}
}, dialogId);
const escapeHint = allowApiKeyFallback
? "K to enter an API key instead, Esc to cancel"
: "Esc to cancel";
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
if (mode === "device") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
@@ -884,7 +911,7 @@ export function OAuthLoginContent(
<strong>{deviceUserCode}</strong>
</text>
<text fg="gray">Visit this URL and enter the code above:</text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
<a href={deviceVerifyUrl}>{deviceVerifyUrl}</a>
</text>
</box>
@@ -892,8 +919,8 @@ export function OAuthLoginContent(
{deviceError && <text fg="red">{deviceError}</text>}
<text fg="gray">
<em>Esc to cancel</em>
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
</text>
</box>
);
@@ -901,7 +928,7 @@ export function OAuthLoginContent(
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
@@ -915,8 +942,83 @@ export function OAuthLoginContent(
{error && <text fg="red">{error}</text>}
<text fg="gray">
<em>Esc to cancel</em>
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
</text>
</box>
);
}
/**
* Manual API key entry for OAuth-capable providers — the escape hatch for
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
* the manual key takes effect (see saveManualProviderApiKey).
*/
export function OAuthApiKeyInputContent(
props: ChoiceContext<boolean> & {
providerId: string;
providerName: string;
providerSettingsManager: ProviderSettingsManager;
},
) {
const {
resolve,
dismiss,
dialogId,
providerId,
providerName,
providerSettingsManager,
} = props;
const [value, setValue] = useState("");
const submit = () => {
const apiKey = value.trim();
if (!apiKey) return;
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
resolve(true);
};
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return") {
submit();
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
<text fg="gray">
Use an API key from your Cline dashboard instead of OAuth login. This
replaces any saved login tokens.
</text>
<box flexDirection="column">
<text fg="gray">API key</text>
<box
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<input
value={value}
onInput={setValue}
placeholder="Paste your API key"
flexGrow={1}
focused
/>
</box>
</box>
<text fg="gray">
<em>Enter to save, Esc to go back</em>
</text>
</box>
);
@@ -142,7 +142,7 @@ export function SkillsPickerContent(props: SkillsPickerContentProps) {
onMouseDown={() => resolve(SKILLS_MARKETPLACE_ACTION)}
height={1}
>
<text fg={isSelected ? palette.textOnSelection : "cyan"}>
<text fg={isSelected ? palette.textOnSelection : palette.act}>
{isSelected ? " " : " "}
Browse more skills at {SKILLS_MARKETPLACE_URL}
</text>
@@ -155,7 +155,7 @@ export function ToolApprovalContent(
<box flexDirection="column" paddingX={1}>
<text fg="yellow">Approve tool call?</text>
<text fg="cyan" marginTop={1}>
<text fg={palette.act} marginTop={1}>
<strong>{props.request.toolName}</strong>
</text>
+6 -6
View File
@@ -30,7 +30,7 @@ export type TextareaHandle = Pick<
export interface InputBarProps {
accent: string;
inputBackground: string;
ruleColor: string;
inputForeground: string;
inputPlaceholder: string;
placeholder: string;
@@ -62,7 +62,7 @@ function readTextPaste(event: PasteEvent): string | null {
export function InputBar(props: InputBarProps) {
const {
accent,
inputBackground,
ruleColor,
inputForeground,
inputPlaceholder,
placeholder,
@@ -197,13 +197,13 @@ export function InputBar(props: InputBarProps) {
<box
flexDirection="row"
alignItems="flex-start"
backgroundColor={inputBackground}
paddingX={2}
paddingY={1}
border={["top", "bottom"]}
borderStyle="single"
borderColor={ruleColor}
onMouseDown={props.onFocusRequest}
>
<text fg={accent}>
<strong>{">"}</strong>
<strong>{""}</strong>
</text>
<box flexGrow={1} paddingLeft={1}>
<textarea
@@ -0,0 +1,106 @@
import type {
ClineRecommendedModel,
ClineRecommendedModelsData,
} from "@cline/core";
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
export interface ClineModelPickerItem {
kind: "model";
model: ClineRecommendedModel;
tier: ClineModelPickerTier;
}
export interface ClineModelPickerBrowse {
kind: "browse";
}
export type ClineModelPickerEntry =
| ClineModelPickerItem
| ClineModelPickerBrowse;
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
ClineModelPickerTier,
string
> = {
recommended: "Recommended",
subscribed: "Subscribed",
free: "Free",
};
// Featured entries for the sectioned picker, keyed by provider: cline gets
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
export function buildFeaturedModelEntries(
providerId: string,
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
return providerId === "cline-pass"
? buildClinePassModelEntries(data)
: buildClineModelEntries(data);
}
function buildClineModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.recommended) {
entries.push({ kind: "model", model: m, tier: "recommended" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
entries.push({ kind: "browse" });
return entries;
}
// Shown under the Free section header when picking a model for ClinePass
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
"Try with limited usage, separate from ClinePass quota.";
// ClinePass shows the subscription's models plus the Cline free models — both
// providers hit the same Cline API, so free models are selectable in place
// (they ride usage billing at $0 instead of the subscription quota).
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
// the ClinePass catalog contains exactly these two buckets, so the sections
// already list every selectable model. An empty clinePass bucket means the
// fetch fell back to the bundled list (which has no pass models) — without an
// escape into the full catalog a subscriber could only pick free models, so
// browse-all comes back in that degraded mode.
function buildClinePassModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.clinePass) {
entries.push({ kind: "model", model: m, tier: "subscribed" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
if (data.clinePass.length === 0) {
entries.push({ kind: "browse" });
}
return entries;
}
// The quota explainer only makes sense in the ClinePass picker, which is the
// only picker that has a "subscribed" section
export function freeTierDescriptionFor(
entries: ClineModelPickerEntry[],
): string | undefined {
const isClinePassPicker = entries.some(
(entry) => entry.kind === "model" && entry.tier === "subscribed",
);
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
}
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
// disambiguate them from their paid twins. Inside the sectioned pickers the
// Free header already says it, so the markers are redundant — but keep them in
// flat lists (e.g. browse-all), where both variants appear side by side.
export function stripFreeMarker(displayName: string): string {
return displayName
.replace(/\s*\(free\)\s*$/i, "")
.replace(/:free$/i, "")
.trim();
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildFeaturedModelEntries,
CLINE_PASS_FREE_SECTION_DESCRIPTION,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
describe("cline model picker entries", () => {
it("builds Recommended/Free sections for the cline provider", () => {
const entries = buildFeaturedModelEntries("cline", {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1")],
});
expect(entries).toEqual([
{
kind: "model",
model: model("anthropic/claude-sonnet-5"),
tier: "recommended",
},
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
{ kind: "browse" },
]);
});
it("builds Subscribed/Free sections for the cline-pass provider", () => {
const entries = buildFeaturedModelEntries("cline-pass", {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
});
expect(entries).toEqual([
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
{
kind: "model",
model: model("cline-pass/kimi-k2.6"),
tier: "subscribed",
},
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
]);
});
it("adds the browse-all escape when the clinePass bucket is empty", () => {
// The fetch fell back to the bundled list (no pass models); the sections
// alone would leave a subscriber able to pick only free models.
const entries = buildFeaturedModelEntries("cline-pass", {
recommended: [],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [],
});
expect(entries).toEqual([
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
{ kind: "browse" },
]);
});
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
const data = {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1")],
};
expect(
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
expect(
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
).toBe(undefined);
});
it("strips redundant free markers from display names", () => {
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
"Trinity Large Preview",
);
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
});
});
@@ -1,7 +1,6 @@
// @jsxImportSource @opentui/react
import {
type ClineRecommendedModel,
type ClineRecommendedModelsData,
fetchClineRecommendedModels,
} from "@cline/core";
@@ -9,25 +8,28 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { palette } from "../../palette";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
export interface ClineModelPickerItem {
kind: "model";
model: ClineRecommendedModel;
tier: "recommended" | "free";
}
export interface ClineModelPickerBrowse {
kind: "browse";
}
export type ClineModelPickerEntry =
| ClineModelPickerItem
| ClineModelPickerBrowse;
export {
buildFeaturedModelEntries,
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerBrowse,
type ClineModelPickerEntry,
type ClineModelPickerItem,
type ClineModelPickerTier,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return "cyan";
return palette.act;
}
function resolveDisplayName(
@@ -39,12 +41,13 @@ function resolveDisplayName(
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
if (hit?.name) return stripFreeMarker(hit.name);
}
}
return modelId.includes("/")
const fallback = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function useClineRecommendedModels() {
@@ -68,20 +71,6 @@ export function useClineRecommendedModels() {
return { data, loading };
}
export function buildClineModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.recommended) {
entries.push({ kind: "model", model: m, tier: "recommended" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
entries.push({ kind: "browse" });
return entries;
}
export function ClineModelPicker(props: {
entries: ClineModelPickerEntry[];
selected: number;
@@ -103,6 +92,7 @@ export function ClineModelPicker(props: {
let lastTier: string | null = null;
let isFirstHeader = true;
const rows: ReactNode[] = [];
const freeTierDescription = freeTierDescriptionFor(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -112,14 +102,20 @@ export function ClineModelPicker(props: {
if (entry.kind === "model") {
if (entry.tier !== lastTier) {
lastTier = entry.tier;
const label = entry.tier === "recommended" ? "Recommended" : "Free";
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
rows.push(
<box
key={`tier-${entry.tier}`}
paddingX={1}
marginTop={isFirstHeader ? 0 : 1}
flexDirection="column"
>
<text fg="gray">{label}</text>
{entry.tier === "free" && freeTierDescription && (
<text fg="gray">
<em>{freeTierDescription}</em>
</text>
)}
</box>,
);
isFirstHeader = false;
@@ -3,7 +3,12 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import type { ClineModelPickerEntry } from "./cline-model-picker";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-picker";
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
import { ProviderRow } from "./provider-row";
@@ -17,7 +22,7 @@ type ClineModelEntriesState =
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return "cyan";
return palette.act;
}
function resolveDisplayName(
@@ -29,12 +34,13 @@ function resolveDisplayName(
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
if (hit?.name) return stripFreeMarker(hit.name);
}
}
return modelId.includes("/")
const fallback = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function ClineModelSelectorContent(
@@ -62,11 +68,13 @@ export function ClineModelSelectorContent(
key: string;
kind: "header" | "model" | "browse";
label: string;
description?: string;
tags: string[];
isCurrent: boolean;
entryIndex: number;
}[] = [];
let lastTier: string | null = null;
const freeTierDescription = freeTierDescriptionFor(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry) continue;
@@ -76,7 +84,9 @@ export function ClineModelSelectorContent(
rows.push({
key: `tier-${entry.tier}`,
kind: "header",
label: entry.tier === "recommended" ? "Recommended" : "Free",
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
description:
entry.tier === "free" ? freeTierDescription : undefined,
tags: [],
isCurrent: false,
entryIndex: -1,
@@ -156,8 +166,18 @@ export function ClineModelSelectorContent(
if (row.kind === "header") {
const isFirst = idx === 0;
return (
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
<box
key={row.key}
paddingX={1}
marginTop={isFirst ? 0 : 1}
flexDirection="column"
>
<text fg="gray">{row.label}</text>
{row.description && (
<text fg="gray">
<em>{row.description}</em>
</text>
)}
</box>
);
}
@@ -272,7 +292,7 @@ export function ClineModelSelectorDialogContent(
if (state.status === "error") {
return (
<box flexDirection="column" gap={1}>
<text fg="cyan">Choose a model</text>
<text fg={palette.act}>Choose a model</text>
<ProviderRow providerName={props.currentProviderName} focused={false} />
<text fg="red">{state.message}</text>
<text fg="gray">R to retry, Esc to go back</text>
@@ -282,7 +302,7 @@ export function ClineModelSelectorDialogContent(
return (
<box flexDirection="column" gap={1}>
<text fg="cyan">Choose a model</text>
<text fg={palette.act}>Choose a model</text>
<ProviderRow providerName={props.currentProviderName} focused={false} />
<text fg="gray">{state.message}</text>
<text fg="gray">Esc to go back</text>
@@ -329,7 +329,8 @@ export function ThinkingLevelContent(
) {
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
const [selected, setSelected] = useState(() => {
const idx = THINKING_LEVELS.findIndex((l) => l.value === currentLevel);
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
return idx >= 0 ? idx : 0;
});
@@ -13,7 +13,7 @@ export function ProviderRow({
<text fg={focused ? palette.selection : "gray"} flexShrink={0}>
{focused ? "" : " "}
</text>
<text fg={focused ? palette.selection : "cyan"} flexShrink={0}>
<text fg={focused ? palette.selection : palette.act} flexShrink={0}>
Provider:
</text>
<text fg="white">{providerName}</text>
+45 -12
View File
@@ -14,14 +14,14 @@ describe("createContextBar", () => {
it("keeps a stable width while changing segment lengths", () => {
expect(createContextBar(0, 100)).toEqual({
filled: "",
empty: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
empty: "\u2588\u2588\u2588\u2588\u2588\u2588",
});
expect(createContextBar(50, 100)).toEqual({
filled: "\u2588\u2588\u2588\u2588",
empty: "\u2588\u2588\u2588\u2588",
filled: "\u2588\u2588\u2588",
empty: "\u2588\u2588\u2588",
});
expect(createContextBar(100, 100)).toEqual({
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
empty: "",
});
});
@@ -29,17 +29,17 @@ describe("createContextBar", () => {
it("shows a non-empty fill when usage is above zero", () => {
expect(createContextBar(7_000, 1_000_000)).toEqual({
filled: "\u2588",
empty: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
empty: "\u2588\u2588\u2588\u2588\u2588",
});
});
it("reserves the final segment for usage at or above the limit", () => {
expect(createContextBar(999_999, 1_000_000)).toEqual({
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
filled: "\u2588\u2588\u2588\u2588\u2588",
empty: "\u2588",
});
expect(createContextBar(1_000_000, 1_000_000)).toEqual({
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
empty: "",
});
});
@@ -58,22 +58,32 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
providerId: "cline",
}),
).toBe("(12,345 tokens) $0.12");
).toBe("(12,345) $0.12");
});
it("displays subscription message when the provider is a subscription provider", () => {
it("rounds cost to two decimals even when tiny", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.0004,
providerId: "cline",
}),
).toBe("(12,345) $0.00");
});
it("hides cost entirely for subscription providers", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline-pass",
}),
).toBe("(12,345 tokens) $0.00 (included with subscription)");
).toBe("(12,345)");
});
});
describe("resolveModelDisplayName", () => {
it("keeps ClinePass visible when model ids have provider prefixes", () => {
it("uses the friendly model name with a ClinePass prefix", () => {
expect(
resolveModelDisplayName({
providerId: "cline-pass",
@@ -82,7 +92,30 @@ describe("resolveModelDisplayName", () => {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("ClinePass/glm-5.2");
).toBe("ClinePass: GLM 5.2");
});
it("falls back to the bare model id with a ClinePass prefix when unknown", () => {
expect(
resolveModelDisplayName({
providerId: "cline-pass",
modelId: "zai/glm-5.2",
}),
).toBe("ClinePass: glm-5.2");
});
it("keeps the reasoning effort next to the model name", () => {
expect(
resolveModelDisplayName({
providerId: "cline-pass",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
thinking: true,
reasoningEffort: "high",
}),
).toBe("ClinePass: GLM 5.2 (high)");
});
it("uses the friendly model name for non-ClinePass providers", () => {
+9 -9
View File
@@ -18,7 +18,7 @@ import { HOME_VIEW_MAX_WIDTH } from "../types";
export function createContextBar(
used: number,
total?: number,
width = 8,
width = 6,
): { filled: string; empty: string } {
const normalizedWidth = Math.max(0, Math.floor(width));
const ratio = total && total > 0 ? Math.min(used / total, 1) : 0;
@@ -45,13 +45,13 @@ export function resolveContextBarFilledForeground(
}
function formatCost(cost: number): string {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
return `$${cost.toFixed(2)}`;
}
function formatCostText(providerId: string, totalCost: number): string {
// Subscription providers (ClinePass) have no per-use cost worth surfacing.
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
return "$0.00 (included with subscription)";
return "";
}
if (!shouldShowCliUsageCost(providerId)) {
@@ -66,7 +66,7 @@ export function formatStatusBarUsageText(input: {
totalCost: number;
providerId: string;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
const tokens = `(${input.totalTokens.toLocaleString()})`;
const costText = formatCostText(input.providerId, input.totalCost);
if (!costText) {
@@ -102,12 +102,12 @@ export function resolveModelDisplayName(config: {
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
const displayName =
config.providerId === "cline-pass"
? `ClinePass/${modelIdTail}`
: (info?.name ?? modelIdTail);
let displayName = info?.name ?? modelIdTail;
if (config.thinking && config.reasoningEffort) {
return `${displayName} (${config.reasoningEffort})`;
displayName = `${displayName} (${config.reasoningEffort})`;
}
if (config.providerId === "cline-pass") {
displayName = `ClinePass: ${displayName}`;
}
return displayName;
}
+12 -4
View File
@@ -103,9 +103,16 @@ export function SessionProvider(props: {
const [hasSubmitted, setHasSubmitted] = useState(
(initialEntries?.length ?? 0) > 0,
);
const [uiMode, setUiMode] = useState<AgentMode>(
const [uiMode, _setUiMode] = useState<AgentMode>(
config.mode === "plan" ? "plan" : "act",
);
// Mirror for appendEntry: entries are appended from event-handler
// callbacks that must see the mode at append time, not at closure time.
const uiModeRef = useRef<AgentMode>(config.mode === "plan" ? "plan" : "act");
const setUiMode = useCallback((mode: AgentMode) => {
uiModeRef.current = mode;
_setUiMode(mode);
}, []);
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
const autoApproveAllRef = useRef(initialAutoApproveAll);
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
@@ -132,8 +139,9 @@ export function SessionProvider(props: {
);
const appendEntry = useCallback((entry: ChatEntry) => {
const stamped = entry.mode ? entry : { ...entry, mode: uiModeRef.current };
setEntries((prev) => {
const next = [...prev, entry];
const next = [...prev, stamped];
return next.length <= MAX_BUFFERED_LINES
? next
: next.slice(next.length - MAX_BUFFERED_LINES);
@@ -188,8 +196,8 @@ export function SessionProvider(props: {
}, []);
const toggleMode = useCallback(() => {
setUiMode((m) => (m === "act" ? "plan" : "act"));
}, []);
setUiMode(uiModeRef.current === "act" ? "plan" : "act");
}, [setUiMode]);
const toggleAutoApprove = useCallback(() => {
const next = !autoApproveAllRef.current;
@@ -3,6 +3,7 @@ import type { OpenConfigOptions } from "./use-config-panel";
export interface LocalSlashCommandActionInput {
name: string;
isRunning: boolean;
openAccount: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
@@ -46,7 +47,12 @@ export function runLocalSlashCommandAction(
return true;
}
if (normalized === "compact") {
input.runCompact();
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
// shared running state.
if (!input.isRunning) {
input.runCompact();
}
return true;
}
if (normalized === "fork") {
@@ -7,7 +7,10 @@ import {
type AccountDialogAction,
AccountDialogContent,
} from "../components/dialogs/account-dialog";
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
import {
OAuthLoginContent,
type OAuthLoginResult,
} from "../components/dialogs/provider-picker";
import type { OpenModelSelectorOptions } from "./use-model-selector";
export function useAccountDialog(opts: {
@@ -60,14 +63,14 @@ export function useAccountDialog(opts: {
return;
}
if (action === "login") {
const saved = await dialog.choice<boolean>({
const saved = await dialog.choice<OAuthLoginResult>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
),
});
if (saved) {
if (saved === true) {
await onAccountChange?.();
await openAccountDialog();
return;
+87 -3
View File
@@ -6,13 +6,14 @@ import type {
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveStatusNoticeLabel } from "../../utils/events";
import { resolveNonCompactionStatusLabel } from "../../utils/events";
import {
formatToolInput,
formatToolOutput,
truncate,
} from "../../utils/helpers";
import type { ChatEntry, InlineStream, TuiProps } from "../types";
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
interface AgentEventDeps {
appendEntry: (entry: ChatEntry) => void;
@@ -32,6 +33,7 @@ interface AgentEventDeps {
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
const openCompactionEntryRef = useRef(false);
const {
appendEntry,
updateLastEntry,
@@ -45,6 +47,47 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
verbose,
} = deps;
// Compaction dividers that arrived while an assistant message was still
// streaming. Appending them immediately would split the message in two, so
// they are held until the active content block closes (or the turn ends).
const pendingCompactionEntriesRef = useRef<
Extract<ChatEntry, { kind: "compaction" }>[]
>([]);
const flushPendingCompactionEntries = useCallback(() => {
const pending = pendingCompactionEntriesRef.current;
if (pending.length === 0) return;
pendingCompactionEntriesRef.current = [];
for (const entry of pending) {
if (entry.status !== "started" && openCompactionEntryRef.current) {
updateEntry((current) =>
current.kind === "compaction" && current.status === "started"
? { ...current, ...entry }
: current,
);
openCompactionEntryRef.current = false;
} else {
appendEntry(entry);
if (entry.status === "started") {
openCompactionEntryRef.current = true;
}
}
}
}, [appendEntry, updateEntry]);
const finalizeDanglingCompactionEntry = useCallback(
(status: "failed" | "cancelled") => {
if (!openCompactionEntryRef.current) return;
openCompactionEntryRef.current = false;
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status }
: entry,
);
},
[updateEntry],
);
const closeToolEntry = useCallback(
(event: AgentEvent & { type: "content_end" }) => {
const error = event.error ?? undefined;
@@ -84,9 +127,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(true);
setIsStreaming(true);
closeInlineStream();
flushPendingCompactionEntries();
break;
case "iteration_end":
closeInlineStream();
flushPendingCompactionEntries();
break;
case "content_start": {
setIsStreaming(false);
@@ -165,11 +210,15 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("cancelled");
break;
case "error":
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("failed");
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
@@ -181,8 +230,40 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
break;
case "notice":
if (event.displayRole === "status") {
closeInlineStream();
const label = resolveStatusNoticeLabel(event);
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (!compaction) {
closeInlineStream();
}
if (compaction) {
if (activeInlineStreamRef.current) {
// An assistant message is still streaming; appending now
// would split it around the divider. Hold the divider (final
// state until the content block closes, then reconcile it
// with the same open divider atomically.
pendingCompactionEntriesRef.current.push({
kind: "compaction",
...compaction,
});
break;
}
if (compaction.status === "started") {
appendEntry({ kind: "compaction", ...compaction });
openCompactionEntryRef.current = true;
} else if (openCompactionEntryRef.current) {
// Finalize the in-progress divider in place, wherever it
// sits in the transcript.
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, ...compaction }
: entry,
);
openCompactionEntryRef.current = false;
} else {
appendEntry({ kind: "compaction", ...compaction });
}
break;
}
const label = resolveNonCompactionStatusLabel(event);
if (label) {
appendEntry({ kind: "status", text: label });
}
@@ -200,6 +281,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
[
appendEntry,
updateLastEntry,
updateEntry,
closeInlineStream,
activeInlineStreamRef,
setIsRunning,
@@ -208,6 +290,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
onTurnErrorReported,
verbose,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
],
);
@@ -9,6 +9,7 @@ function makeActions(
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
): Omit<LocalSlashCommandActionInput, "name"> {
return {
isRunning: false,
openAccount: vi.fn(),
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
@@ -58,6 +59,32 @@ describe("runLocalSlashCommandAction", () => {
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
});
it("does not start compaction while a turn is running", () => {
const runCompact = vi.fn();
const actions = makeActions({ isRunning: true, runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).not.toHaveBeenCalled();
});
it("starts compaction while the session is idle", () => {
const runCompact = vi.fn();
const actions = makeActions({ runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).toHaveBeenCalledOnce();
});
it("waits for clear to reset the runtime session", async () => {
let resolveClear: (() => void) | undefined;
const clearConversation = vi.fn(
@@ -161,7 +188,7 @@ describe("formatCompactionStatus", () => {
messagesAfter: 300,
compacted: true,
}),
).toBe("Compacted context; message count stayed at 300.");
).toBe("Compacted context; message count stayed at 300 messages.");
});
it("reports empty sessions separately", () => {
@@ -9,7 +9,6 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { AppView, TuiProps } from "../types";
import { formatCompactionStatus } from "../utils/compaction-status";
import { hydrateSessionMessages } from "../utils/hydrate-messages";
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
import { HistoryDialogContent } from "../views/history-view";
@@ -75,9 +74,10 @@ export function useLocalCommandActions(input: {
});
} else {
session.clearEntries();
for (const entry of entries) {
session.appendEntry(entry);
}
// replaceEntries rather than appendEntry: appendEntry
// stamps unstamped entries with the CURRENT mode, which
// would lock hydrated history to the resume-time accent.
session.replaceEntries(entries);
if (typeof result.currentContextSize === "number") {
session.setLastTotalTokens(result.currentContextSize);
}
@@ -115,21 +115,42 @@ export function useLocalCommandActions(input: {
}, [dialog, refocusTextarea, termHeight]);
const runCompact = useCallback(async () => {
session.setIsRunning(true);
session.appendEntry({
kind: "status",
text: "Compacting context...",
kind: "compaction",
compactionMode: "manual",
status: "started",
});
try {
const result = await onCompact();
session.updateLastEntry(() => ({
kind: "status",
text: formatCompactionStatus(result),
}));
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? {
...entry,
status: result.compacted ? "completed" : "skipped",
messagesBefore: result.messagesBefore,
messagesAfter:
result.workingContextMessagesAfter ?? result.messagesAfter,
}
: entry,
);
} catch (error) {
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
const cancelled =
error instanceof Error &&
(error.name === "AbortError" || /abort/i.test(error.message));
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status: cancelled ? "cancelled" : "failed" }
: entry,
);
if (!cancelled) {
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
} finally {
session.setIsRunning(false);
}
}, [onCompact, session]);
@@ -158,6 +179,15 @@ export function useLocalCommandActions(input: {
kind: "status",
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
}));
if (result.carriedWorkingContext) {
session.appendEntry({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: result.carriedWorkingContext.canonicalMessages,
messagesAfter: result.carriedWorkingContext.workingContextMessages,
});
}
} else {
session.updateLastEntry(() => ({
kind: "error",
@@ -180,6 +210,7 @@ export function useLocalCommandActions(input: {
}
return runLocalSlashCommandAction({
name: resolved.name,
isRunning: session.isRunning,
invocation,
openAccount,
openConfig,
@@ -208,6 +239,7 @@ export function useLocalCommandActions(input: {
openSkills,
runCompact,
runFork,
session.isRunning,
slashCommandRegistry,
],
);
+104 -6
View File
@@ -6,6 +6,7 @@ import {
refreshProviderModelsFromSource,
resolveProviderConfig,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
@@ -21,12 +22,14 @@ import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
OAuthApiKeyInputContent,
OAuthLoginContent,
type OAuthLoginResult,
ProviderConfigInputContent,
ProviderPickerContent,
UseExistingOrReconfigureContent,
} from "../components/dialogs/provider-picker";
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
import {
BROWSE_ALL_ACTION,
ClineModelSelectorDialogContent,
@@ -79,6 +82,51 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
/**
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
* using the provider's stored API key and headers, mirroring the extension's
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
* to manual model-id entry.
*/
async function fetchOpenAiCompatibleModelIds(
providerId: string,
): Promise<string[]> {
try {
const manager = new ProviderSettingsManager();
const config = manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
if (!baseUrl || !URL.canParse(baseUrl)) return [];
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
const apiKey = config?.apiKey?.trim();
if (
apiKey &&
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return [];
const payload = (await response.json()) as { data?: unknown };
const list = Array.isArray(payload?.data) ? payload.data : [];
const ids = list
.map((model) => {
const id = (model as { id?: unknown } | null)?.id;
return typeof id === "string" ? id.trim() : "";
})
.filter(Boolean);
return [...new Set(ids)];
} catch {
return [];
}
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
@@ -131,6 +179,23 @@ async function runProviderChange(
);
const existingSettings = manager.getProviderSettings(newProviderId);
// Manual API key entry is the escape hatch for when OAuth login isn't
// working; only the Cline providers accept a dashboard API key.
const supportsManualApiKey = isClineProvider(newProviderId);
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<OAuthApiKeyInputContent
{...ctx}
providerId={newProviderId}
providerName={displayName}
providerSettingsManager={manager}
/>
),
});
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
let option: ExistingProviderOption | undefined;
@@ -165,17 +230,22 @@ async function runProviderChange(
if (needsAuth) {
let saved: boolean | undefined;
if (isOAuthProvider(newProviderId)) {
saved = await dialog.choice<boolean>({
const loginResult = await dialog.choice<OAuthLoginResult>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
<OAuthLoginContent
{...ctx}
providerId={newProviderId}
providerName={displayName}
allowApiKeyFallback={supportsManualApiKey}
/>
),
});
saved =
loginResult === "use_api_key"
? await openManualApiKeyDialog()
: loginResult;
} else if (isOpenAICodexCliProvider(newProviderId)) {
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
@@ -275,12 +345,28 @@ export function useModelSelector(opts: {
config.knownModels as Record<string, Llms.ModelInfo>,
);
let providerDisplayName = config.providerId;
let endpointModelOptions: ModelOption[] = [];
const refreshProviderContext = async () => {
modelOptions = buildModelOptions(
config.knownModels as Record<string, Llms.ModelInfo>,
);
providerDisplayName = await getProviderDisplayName(config.providerId);
// Free-text providers (openai-compatible) can still suggest model
// ids when their endpoint answers /models; otherwise they keep the
// manual input.
endpointModelOptions = usesModelIdInput(config.providerId)
? buildModelOptions(
Object.fromEntries(
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
(id) => [id, { id, name: id }],
),
),
)
: [];
if (endpointModelOptions.length > 0) {
modelOptions = endpointModelOptions;
}
};
if (!options?.startWithProviderChange) {
@@ -316,7 +402,10 @@ export function useModelSelector(opts: {
let pickingModel = true;
while (pickingModel) {
if (usesModelIdInput(config.providerId)) {
if (
usesModelIdInput(config.providerId) &&
endpointModelOptions.length === 0
) {
const modelId = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -341,7 +430,13 @@ export function useModelSelector(opts: {
continue;
}
if (config.providerId === "cline") {
if (
config.providerId === "cline" ||
config.providerId === "cline-pass"
) {
// ClinePass gets the same sectioned picker with Subscribed/Free
// sections — free models are selectable while staying on ClinePass
const featuredProviderId = config.providerId;
const clineResult = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -351,7 +446,10 @@ export function useModelSelector(opts: {
currentProviderName={providerDisplayName}
knownModels={config.knownModels as Record<string, unknown>}
loadEntries={async () =>
buildClineModelEntries(await fetchClineRecommendedModels())
buildFeaturedModelEntries(
featuredProviderId,
await fetchClineRecommendedModels(),
)
}
/>
),
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type TerminalTitleRenderer,
useTerminalTitle,
} from "./use-terminal-title";
const reactMock = vi.hoisted(() => {
const cleanups: Array<() => void> = [];
return {
cleanups,
// Run effect bodies now, but retain their cleanups so each test can move
// the renderer across the native destruction boundary before unmount.
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
const cleanup = effect();
if (cleanup) {
cleanups.push(cleanup);
}
}),
};
});
vi.mock("react", () => ({
useEffect: reactMock.useEffect,
}));
function createTitleRenderer() {
let destroyed = false;
const setTerminalTitle = vi.fn(() => {
if (destroyed) {
throw new Error("setTerminalTitle called after renderer destruction");
}
});
const renderer: TerminalTitleRenderer = {
get isDestroyed() {
return destroyed;
},
setTerminalTitle,
};
return {
destroy: () => {
destroyed = true;
},
renderer,
setTerminalTitle,
};
}
beforeEach(() => {
reactMock.cleanups.length = 0;
reactMock.useEffect.mockClear();
});
describe("useTerminalTitle", () => {
it("sets and resets the title while the renderer is active", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
});
it("does not set the title when its effect runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
titleRenderer.destroy();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
});
it("does not reset the title when cleanup runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
titleRenderer.destroy();
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,29 @@
import { useEffect } from "react";
export interface TerminalTitleRenderer {
readonly isDestroyed: boolean;
setTerminalTitle(title: string): void;
}
export function useTerminalTitle(
renderer: TerminalTitleRenderer,
terminalTitle: string,
): void {
// setTerminalTitle writes into memory owned by the native renderer, so it
// must never run after destroy. React can flush passive effects after the
// renderer's memory has been freed.
useEffect(() => {
if (renderer.isDestroyed) {
return;
}
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
};
}, [renderer]);
}
+37
View File
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
defaultBackground: null,
defaultForeground: null,
})),
isDestroyed: false,
on: vi.fn(),
setTerminalTitle: vi.fn(),
}));
const rootMock = vi.hoisted(() => ({
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
beforeEach(() => {
destroyHandlers.length = 0;
rendererMock.isDestroyed = false;
rendererMock.destroy.mockReset();
rendererMock.setTerminalTitle.mockReset();
rendererMock.on.mockReset();
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
if (event === "destroy") {
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
});
it("resets the terminal title before destroying the renderer", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
await Promise.resolve();
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
const titleCallOrder =
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
});
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
// Simulate OpenTUI's own signal handler destroying the renderer in the
// same dispatch (e.g. an idle SIGTERM fires both our handler and
// OpenTUI's exitHandler before microtasks drain).
rendererMock.isDestroyed = true;
for (const handler of destroyHandlers) {
handler();
}
await Promise.resolve();
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
});
});
+8
View File
@@ -67,6 +67,14 @@ export async function renderOpenTui(
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
// Reset the title while the native renderer is still alive; the
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
// Re-check here: OpenTUI's own signal handlers can destroy the
// renderer between destroy() queuing this microtask and it running
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
renderer.destroy();
});
};
+6 -6
View File
@@ -23,15 +23,15 @@ describe("getTerminalTheme", () => {
});
describe("theme-aware palette helpers", () => {
it("preserves the existing named ANSI colors for dark terminals", () => {
expect(getModeAccent("act", "dark")).toBe("cyan");
expect(getModeAccent("plan", "dark")).toBe("yellow");
expect(getSuccessColor("dark")).toBe("brightGreen");
it("uses the brand accent colors for dark terminals", () => {
expect(getModeAccent("act", "dark")).toBe("#79b8ff");
expect(getModeAccent("plan", "dark")).toBe("#ffea7f");
expect(getSuccessColor("dark")).toBe("#99e89b");
});
it("uses darker accents on light terminals", () => {
expect(getModeAccent("act", "light")).toBe("#0969da");
expect(getModeAccent("plan", "light")).toBe("#9a6700");
expect(getModeAccent("act", "light")).toBe("#0f72cb");
expect(getModeAccent("plan", "light")).toBe("#867100");
expect(getSuccessColor("light")).toBe("#116329");
});
});
+50 -17
View File
@@ -1,9 +1,9 @@
export const palette = {
act: "cyan",
plan: "yellow",
selection: "cyan",
act: "#79b8ff",
plan: "#ffea7f",
selection: "#79b8ff",
error: "red",
success: "brightGreen",
success: "#99e89b",
muted: "gray",
textOnSelection: "black",
} as const;
@@ -16,9 +16,11 @@ export const themePalette = {
plan: palette.plan,
success: palette.success,
},
// Same OKLCH hues as the dark accents, darkened to hold >=4.5:1 contrast
// on white so the plan/act identity carries across themes.
light: {
act: "#0969da",
plan: "#9a6700",
act: "#0f72cb",
plan: "#867100",
success: "#116329",
},
} as const;
@@ -29,7 +31,7 @@ export const diffPalettes = {
removedBg: "#4d1a1a",
addedLineNumberBg: "#1a4d1a",
removedLineNumberBg: "#4d1a1a",
addedSignColor: "#22c55e",
addedSignColor: "#99e89b",
removedSignColor: "#ef4444",
lineNumberFg: "#888888",
},
@@ -75,8 +77,8 @@ export function getSuccessColor(theme: TerminalTheme = "dark"): string {
// overshoot.
// 3. On dark themes, raise L (lighten). On light themes, lower L (darken).
// 4. Nudge the a/b chromatic channels by CHROMA_NUDGE toward the mode's
// accent color. For plan (warm/yellow): +a, +b. For act (cool/cyan):
// -a, +b. At 0.003 this is ~10x below OKLAB's just-noticeable-difference
// accent color. For plan (warm/yellow): +a, +b. For act (cool/blue):
// -a, -b. At 0.003 this is ~10x below OKLAB's just-noticeable-difference
// threshold (~0.03), so it registers as a "feel" rather than visible color.
//
// Sample outputs on common terminals (act mode / plan mode bg):
@@ -131,22 +133,53 @@ export function getDefaultForeground(
return isLightTheme(terminalBg) ? "#1a1a1a" : undefined;
}
export function getModeInputBackground(
mode: string,
function liftedFromTerminalBg(
terminalBg: string | null,
baseLift: number,
nudgeA: number,
nudgeB: number,
): string {
const hex = normalizeHex(terminalBg) ?? "#000000";
const base = hexToOklab(hex);
const light = base.L > LIGHT_THEME_THRESHOLD;
const lift = BASE_LIFT / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
const warm = mode === "plan";
const lift = baseLift / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
return oklabToHex(
base.L + (light ? -lift : lift),
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
base.b + CHROMA_NUDGE,
base.a + nudgeA,
base.b + nudgeB,
);
}
export function getModeInputBackground(
mode: string,
terminalBg: string | null,
): string {
const warm = mode === "plan";
return liftedFromTerminalBg(
terminalBg,
BASE_LIFT,
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
);
}
// The `─` rules framing the input field are thin foreground strokes rather
// than filled cells, so they need a much larger lift than a background tint
// to register at the same perceptual weight — this lands them around mid-gray
// on both black and white terminals. They stay neutral (no mode chroma) so
// the frame doesn't shift color when toggling plan/act.
const RULE_BASE_LIFT = 0.5;
export function getInputRuleColor(terminalBg: string | null): string {
return liftedFromTerminalBg(terminalBg, RULE_BASE_LIFT, 0, 0);
}
// User message bubbles stay neutral (no mode chroma) so the transcript reads
// as history rather than tracking whichever mode is currently active.
export function getUserMessageBackground(terminalBg: string | null): string {
return liftedFromTerminalBg(terminalBg, BASE_LIFT, 0, 0);
}
export function getModeInputForeground(
mode: string,
terminalBg: string | null,
@@ -157,7 +190,7 @@ export function getModeInputForeground(
return oklabToHex(
base.L,
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
base.b + CHROMA_NUDGE,
base.b + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
);
}
@@ -171,7 +204,7 @@ export function getModeInputPlaceholder(
return oklabToHex(
base.L,
base.a + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
base.b + CHROMA_NUDGE * 2,
base.b + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
);
}
+6 -12
View File
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import type { AppView, TuiProps } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
@@ -401,9 +402,10 @@ function App(props: TuiProps) {
if (lastEntry && lastEntry.kind === "user_submitted") {
entries.pop();
}
for (const entry of entries) {
session.appendEntry(entry);
}
// replaceEntries rather than appendEntry: appendEntry stamps
// unstamped entries with the CURRENT mode, which would lock
// hydrated history to the restore-time accent.
session.replaceEntries(entries);
session.setHasSubmitted(entries.length > 0);
setAppView(entries.length > 0 ? "chat" : "home");
populateInputRef.current(picked.fullText);
@@ -471,15 +473,7 @@ function App(props: TuiProps) {
};
}, [renderer, showToast]);
useEffect(() => {
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
renderer.setTerminalTitle("");
};
}, [renderer]);
useTerminalTitle(renderer, terminalTitle);
useEffect(() => {
return () => {
+31 -3
View File
@@ -25,7 +25,7 @@ import type {
} from "./interactive-config";
import type { InteractiveSlashCommand } from "./interactive-welcome";
export type ChatEntry =
export type ChatEntry = (
| { kind: "user"; text: string }
| { kind: "assistant_text"; text: string; streaming: boolean }
| { kind: "reasoning"; text: string; streaming: boolean }
@@ -44,6 +44,15 @@ export type ChatEntry =
}
| { kind: "error"; text: string }
| { kind: "status"; text: string }
| {
kind: "compaction";
compactionMode: "auto" | "manual" | "inherited";
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
tokensBefore?: number;
tokensAfter?: number;
messagesBefore?: number;
messagesAfter?: number;
}
| { kind: "team"; text: string }
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
| {
@@ -52,7 +61,17 @@ export type ChatEntry =
cost: number;
elapsed: string;
iterations: number;
};
}
) & {
/**
* Agent mode active when the entry was produced. Stamped by appendEntry
* (live sessions) and hydrateSessionMessages (resumed sessions) so the
* transcript renders each entry with the accent of its own mode instead
* of retinting everything to the current mode. Absent on entries from
* transcripts that predate mode stamping.
*/
mode?: AgentMode;
};
export interface InteractiveTurnResult {
usage: {
@@ -80,6 +99,7 @@ export interface ResumedSessionResult {
export interface InteractiveCompactionResult {
messagesBefore: number;
messagesAfter: number;
workingContextMessagesAfter?: number;
compacted: boolean;
}
@@ -175,7 +195,15 @@ export interface TuiProps {
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
onCompact: () => Promise<InteractiveCompactionResult>;
onFork: () => Promise<
{ forkedFromSessionId: string; newSessionId: string } | undefined
| {
forkedFromSessionId: string;
newSessionId: string;
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
}
| undefined
>;
getCheckpointData: () => Promise<
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
formatCompactionDividerLabel,
formatTokenCount,
parseCompactionNoticeMetadata,
} from "./compaction-status";
describe("parseCompactionNoticeMetadata", () => {
it("extracts a divider entry from a completed auto-compaction notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
reason: "auto_compaction",
phase: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toEqual({
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
});
});
it("extracts a streaming divider entry from a started notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "started",
}),
).toEqual({ compactionMode: "auto", status: "started" });
});
it("maps manual compaction notices to manual mode", () => {
expect(
parseCompactionNoticeMetadata({
kind: "manual_compaction",
phase: "completed",
})?.compactionMode,
).toBe("manual");
});
it("maps a benign no-result terminal notice to skipped", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "skipped",
}),
).toEqual({ compactionMode: "auto", status: "skipped" });
});
it("ignores non-compaction metadata", () => {
expect(
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
).toBeUndefined();
expect(
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
).toBeUndefined();
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
});
it("drops non-numeric counters instead of rendering garbage", () => {
const parsed = parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "completed",
tokensBefore: "25000",
tokensAfter: Number.NaN,
});
expect(parsed?.tokensBefore).toBeUndefined();
expect(parsed?.tokensAfter).toBeUndefined();
});
});
describe("formatTokenCount", () => {
it("formats counts into compact units", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(6_300)).toBe("6.3k");
expect(formatTokenCount(25_000)).toBe("25k");
expect(formatTokenCount(1_200_000)).toBe("1.2M");
});
});
describe("formatCompactionDividerLabel", () => {
it("includes token and message deltas when present", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
});
it("labels in-progress compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "started",
}),
).toBe("Auto compacting messages");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "started",
}),
).toBe("Compacting messages");
});
it("labels failed and cancelled compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "failed",
}),
).toBe("Compaction failed");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "cancelled",
}),
).toBe("Compaction cancelled");
});
it("labels skipped compaction without calling it cancelled", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "skipped",
}),
).toBe("Compaction skipped");
});
it("labels inherited working context from forks and restarts", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: 60,
messagesAfter: 15,
}),
).toBe("Compacted working context carried over · 60 → 15 messages");
});
it("labels manual compaction and omits missing counters", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "completed",
}),
).toBe("Context compacted (manual)");
});
});
+108 -4
View File
@@ -1,4 +1,105 @@
import type { InteractiveCompactionResult } from "../types";
import type { ChatEntry, InteractiveCompactionResult } from "../types";
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
function formatMessageCount(count: number): string {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
function asFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
/**
* Extracts a compaction divider entry from a status notice's metadata.
* "started" notices produce a streaming (in-progress) divider; "completed"
* notices produce the final divider with counters. Returns undefined for
* non-compaction notices.
*/
export function parseCompactionNoticeMetadata(
metadata: Record<string, unknown> | undefined,
): Omit<CompactionDividerEntry, "kind"> | undefined {
if (
!metadata ||
(metadata.phase !== "started" &&
metadata.phase !== "completed" &&
metadata.phase !== "skipped")
) {
return undefined;
}
const kind = metadata.kind ?? metadata.reason;
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
return undefined;
}
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
if (metadata.phase === "started") {
return { compactionMode, status: "started" };
}
if (metadata.phase === "skipped") {
return { compactionMode, status: "skipped" };
}
return {
compactionMode,
status: "completed",
tokensBefore: asFiniteNumber(metadata.tokensBefore),
tokensAfter: asFiniteNumber(metadata.tokensAfter),
messagesBefore: asFiniteNumber(metadata.messagesBefore),
messagesAfter: asFiniteNumber(metadata.messagesAfter),
};
}
export function formatTokenCount(count: number): string {
if (count < 1_000) {
return `${count}`;
}
if (count < 1_000_000) {
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
export function formatCompactionDividerLabel(
entry: CompactionDividerEntry,
): string {
if (entry.status === "started") {
return entry.compactionMode === "manual"
? "Compacting messages"
: "Auto compacting messages";
}
if (entry.status === "failed") {
return "Compaction failed";
}
if (entry.status === "cancelled") {
return "Compaction cancelled";
}
if (entry.status === "skipped") {
return "Compaction skipped";
}
const parts: string[] = [
entry.compactionMode === "manual"
? "Context compacted (manual)"
: entry.compactionMode === "inherited"
? "Compacted working context carried over"
: "Context compacted",
];
if (
typeof entry.tokensBefore === "number" &&
typeof entry.tokensAfter === "number"
) {
parts.push(
`${formatTokenCount(entry.tokensBefore)}${formatTokenCount(entry.tokensAfter)} tokens`,
);
}
if (
typeof entry.messagesBefore === "number" &&
typeof entry.messagesAfter === "number"
) {
parts.push(`${entry.messagesBefore}${entry.messagesAfter} messages`);
}
return parts.join(" · ");
}
export function formatCompactionStatus(
result: InteractiveCompactionResult,
@@ -9,8 +110,11 @@ export function formatCompactionStatus(
if (!result.compacted) {
return "No compaction needed.";
}
if (result.messagesBefore === result.messagesAfter) {
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
if (typeof result.workingContextMessagesAfter === "number") {
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
}
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
if (result.messagesBefore === result.messagesAfter) {
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
}
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
}
+111 -2
View File
@@ -13,7 +13,7 @@ describe("hydrateSessionMessages", () => {
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "user_submitted", text: "lets do it" },
{ kind: "user_submitted", text: "lets do it", mode: "plan" },
]);
});
@@ -39,7 +39,116 @@ describe("hydrateSessionMessages", () => {
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "assistant_text", text: "On it.", streaming: false },
{ kind: "assistant_text", text: "On it.", streaming: false, mode: "act" },
]);
});
it("stamps entries with the mode of the user message that produced them", () => {
const messages = [
{
role: "user",
content: '<user_input mode="plan">plan this out</user_input>',
},
{ role: "assistant", content: "Here is the plan." },
{
role: "user",
content: '<user_input mode="act">do it</user_input>',
},
{ role: "assistant", content: "Doing it." },
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "user_submitted", text: "plan this out", mode: "plan" },
{
kind: "assistant_text",
text: "Here is the plan.",
streaming: false,
mode: "plan",
},
{ kind: "user_submitted", text: "do it", mode: "act" },
{
kind: "assistant_text",
text: "Doing it.",
streaming: false,
mode: "act",
},
]);
});
it("switches to act mode after a switch_to_act_mode tool call", () => {
const messages = [
{
role: "user",
content: '<user_input mode="plan">plan then build</user_input>',
},
{
role: "assistant",
content: [
{ type: "text", text: "Plan looks good, switching." },
{
type: "tool_use",
id: "tool-1",
name: "switch_to_act_mode",
input: {},
},
{ type: "text", text: "Building now." },
],
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "user_submitted", text: "plan then build", mode: "plan" },
{
kind: "assistant_text",
text: "Plan looks good, switching.",
streaming: false,
mode: "plan",
},
{
kind: "tool_call",
toolName: "switch_to_act_mode",
inputSummary: expect.any(String),
rawInput: {},
streaming: false,
mode: "plan",
},
{
kind: "assistant_text",
text: "Building now.",
streaming: false,
mode: "act",
},
]);
});
it("strips mode switch notices from displayed user text", () => {
const messages = [
{
role: "user",
content:
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "user_submitted", text: "are you okay?", mode: "plan" },
]);
});
it("leaves mode undefined for transcripts without user_input wrappers", () => {
const messages = [
{ role: "user", content: "plain old message" },
{ role: "assistant", content: "reply" },
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "user_submitted", text: "plain old message", mode: undefined },
{
kind: "assistant_text",
text: "reply",
streaming: false,
mode: undefined,
},
]);
});
});
+25 -3
View File
@@ -1,4 +1,9 @@
import { formatDisplayUserInput, type Message } from "@cline/shared";
import type { AgentMode } from "@cline/core";
import {
formatDisplayUserInput,
type Message,
parseUserInputMode,
} from "@cline/shared";
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
import { formatToolInput } from "../../utils/helpers";
import type { ChatEntry } from "../types";
@@ -37,6 +42,12 @@ function stringifyToolResult(
export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
const entries: ChatEntry[] = [];
const toolUseMap = new Map<string, number>();
// Mode each entry was produced in, recovered from <user_input mode="...">
// wrappers and switch_to_act_mode tool calls as we walk the transcript.
// Stays undefined for transcripts with no mode markers (pre-wrapper
// builds, or transcripts laundered by older builds that stripped the
// wrappers on session restarts).
let mode: AgentMode | undefined;
for (const msg of messages as PersistedMessage[]) {
const displayRole = getDisplayRole(msg);
@@ -46,15 +57,17 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
if (typeof msg.content === "string") {
if (msg.role === "user") {
mode = parseUserInputMode(msg.content) ?? mode;
const text = formatDisplayUserInput(msg.content);
if (text && !isSyntheticUserText(text)) {
entries.push({ kind: "user_submitted", text });
entries.push({ kind: "user_submitted", text, mode });
}
} else {
entries.push({
kind: "assistant_text",
text: msg.content,
streaming: false,
mode,
});
}
continue;
@@ -71,6 +84,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
kind: "assistant_text",
text: block.text,
streaming: false,
mode,
});
}
continue;
@@ -81,6 +95,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
kind: "reasoning",
text: block.thinking,
streaming: false,
mode,
});
continue;
}
@@ -96,8 +111,14 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
inputSummary: formatToolInput(block.name, block.input),
rawInput: block.input,
streaming: false,
mode,
});
toolUseMap.set(block.id, entries.length - 1);
// The switch tool flips the session to act mid-run; everything
// after it was produced in act mode.
if (block.name === "switch_to_act_mode") {
mode = "act";
}
continue;
}
@@ -123,9 +144,10 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
if (msg.role === "user" && userTextParts.length > 0) {
const combined = userTextParts.join("\n");
mode = parseUserInputMode(combined) ?? mode;
const text = formatDisplayUserInput(combined);
if (text && !isSyntheticUserText(text)) {
entries.push({ kind: "user_submitted", text });
entries.push({ kind: "user_submitted", text, mode });
}
}
}
@@ -49,4 +49,33 @@ describe("getSyntaxStyle", () => {
expect(style?.fg?.toInts()).toEqual([26, 26, 26, 255]);
});
it("tints markdown accents by mode", () => {
// act #79b8ff vs plan #ffea7f (dark theme accents)
expect(
getSyntaxStyle("dark", "act").getStyle("markup.heading")?.fg?.toInts(),
).toEqual([0x79, 0xb8, 0xff, 255]);
expect(
getSyntaxStyle("dark", "plan").getStyle("markup.heading")?.fg?.toInts(),
).toEqual([0xff, 0xea, 0x7f, 255]);
expect(
getSyntaxStyle("dark", "plan").getStyle("markup.link")?.fg?.toInts(),
).toEqual([0xff, 0xea, 0x7f, 255]);
});
it("tints light-theme markdown accents by mode", () => {
// act #0f72cb vs plan #867100 (light theme accents)
expect(
getSyntaxStyle("light", "act").getStyle("markup.heading")?.fg?.toInts(),
).toEqual([0x0f, 0x72, 0xcb, 255]);
expect(
getSyntaxStyle("light", "plan").getStyle("markup.heading")?.fg?.toInts(),
).toEqual([0x86, 0x71, 0x00, 255]);
});
it("keeps code token colors constant across modes", () => {
expect(getSyntaxStyle("dark", "plan").getStyle("keyword")).toEqual(
getSyntaxStyle("dark", "act").getStyle("keyword"),
);
});
});
+44 -35
View File
@@ -1,10 +1,12 @@
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
import type { TerminalTheme } from "../palette";
import { type TerminalTheme, themePalette } from "../palette";
const instances: Record<TerminalTheme, SyntaxStyle | null> = {
dark: null,
light: null,
};
// Markdown's prominent elements (headings, bold, list markers, links) take
// the accent of the mode the content was produced in, so assistant output
// reads plan-yellow or act-blue alongside the rest of the transcript.
export type SyntaxAccentMode = "act" | "plan";
const instances = new Map<string, SyntaxStyle>();
interface SyntaxColors {
keyword: string;
@@ -22,34 +24,34 @@ interface SyntaxColors {
attribute: string;
escape: string;
markdownCode: string;
markdownHeading: string;
markdownMuted: string;
markdownLink: string;
markdownItalic: string;
markdownDefault?: string;
}
// Dark syntax colors are a pastel family harmonized with the brand accents
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
// part of the same palette instead of a bolted-on editor theme.
const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
dark: {
keyword: "#c678dd",
operator: "#56b6c2",
type: "#e5c07b",
functionName: "#61afef",
variable: "#e06c75",
string: "#98c379",
number: "#d19a66",
keyword: "#d7a0e3",
operator: "#9bbbdd",
type: "#dfca7d",
functionName: themePalette.dark.act,
variable: "#ee939b",
string: "#99e89b",
number: "#f0ad7f",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#e06c75",
constant: "#d19a66",
tag: "#e06c75",
attribute: "#d19a66",
escape: "#56b6c2",
markdownCode: "#98c379",
markdownHeading: "#56b6c2",
property: "#ee939b",
constant: "#f0ad7f",
tag: "#ee939b",
attribute: "#f0ad7f",
escape: "#9bbbdd",
markdownCode: "#99e89b",
markdownMuted: "#808080",
markdownLink: "#56b6c2",
markdownItalic: "#e5c07b",
markdownItalic: "#dfca7d",
},
light: {
keyword: "#cf222e",
@@ -67,9 +69,7 @@ const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
attribute: "#0550ae",
escape: "#0550ae",
markdownCode: "#116329",
markdownHeading: "#0969da",
markdownMuted: "#6e7781",
markdownLink: "#0969da",
markdownItalic: "#8250df",
markdownDefault: "#1a1a1a",
},
@@ -91,16 +91,16 @@ function italic(hex: string): StyleDefinition {
return { fg: color(hex), italic: true };
}
function underline(hex: string): StyleDefinition {
return { fg: color(hex), underline: true };
}
function buildSyntaxStyle(theme: TerminalTheme): SyntaxStyle {
function buildSyntaxStyle(
theme: TerminalTheme,
mode: SyntaxAccentMode,
): SyntaxStyle {
const colors = syntaxColors[theme];
const markdownHeading = color(colors.markdownHeading);
const accent = color(themePalette[theme][mode]);
const markdownHeading = accent;
const markdownCode = color(colors.markdownCode);
const markdownMuted = color(colors.markdownMuted);
const markdownLink = color(colors.markdownLink);
const markdownLink = accent;
return SyntaxStyle.fromStyles({
...(colors.markdownDefault ? { default: fg(colors.markdownDefault) } : {}),
@@ -145,10 +145,19 @@ function buildSyntaxStyle(theme: TerminalTheme): SyntaxStyle {
"markup.link.url": { fg: markdownLink, underline: true },
label: { fg: markdownLink },
conceal: { fg: markdownMuted },
"string.special.url": underline(colors.markdownLink),
"string.special.url": { fg: markdownLink, underline: true },
});
}
export function getSyntaxStyle(theme: TerminalTheme = "dark"): SyntaxStyle {
return (instances[theme] ??= buildSyntaxStyle(theme));
export function getSyntaxStyle(
theme: TerminalTheme = "dark",
mode: SyntaxAccentMode = "act",
): SyntaxStyle {
const key = `${theme}:${mode}`;
let style = instances.get(key);
if (!style) {
style = buildSyntaxStyle(theme, mode);
instances.set(key, style);
}
return style;
}
+4 -2
View File
@@ -20,6 +20,7 @@ import {
useTerminalTheme,
} from "../hooks/use-terminal-background";
import {
getInputRuleColor,
getModeAccent,
getModeInputBackground,
getModeInputForeground,
@@ -76,6 +77,7 @@ export function ChatView(props: {
const terminalTheme = useTerminalTheme();
const accent = getModeAccent(session.uiMode, terminalTheme);
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
const inputRuleColor = getInputRuleColor(terminalBg);
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
const placeholder =
@@ -123,10 +125,10 @@ export function ChatView(props: {
/>
)}
<box marginBottom={1}>
<box>
<InputBar
accent={accent}
inputBackground={inputBackground}
ruleColor={inputRuleColor}
inputForeground={inputForeground}
inputPlaceholder={inputPlaceholder}
placeholder={placeholder}
+6 -6
View File
@@ -721,7 +721,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
return (
<box flexDirection="column" paddingX={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>Settings</strong>
</text>
@@ -793,7 +793,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
flexDirection="row"
justifyContent="space-between"
>
<text fg={isSel ? "cyan" : undefined}>{pfx}Provider</text>
<text fg={isSel ? palette.act : undefined}>{pfx}Provider</text>
<text fg="white">{props.providerDisplayName}</text>
</box>
);
@@ -804,7 +804,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
flexDirection="row"
justifyContent="space-between"
>
<text fg={isSel ? "cyan" : undefined}>{pfx}Model</text>
<text fg={isSel ? palette.act : undefined}>{pfx}Model</text>
<text fg="white">{displayName}</text>
</box>
);
@@ -833,7 +833,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
flexDirection="row"
justifyContent="space-between"
>
<text fg={isSel ? "cyan" : undefined}>
<text fg={isSel ? palette.act : undefined}>
{pfx}
{row.label}
</text>
@@ -866,7 +866,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: enabledState === "partial"
? "yellow"
: isSel
? "cyan"
? palette.act
: "gray";
return (
<box
@@ -886,7 +886,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
case "mcp-manager":
return (
<text key={absIdx} fg={isSel ? "cyan" : "gray"}>
<text key={absIdx} fg={isSel ? palette.act : "gray"}>
{pfx}Manage MCP Servers...
</text>
);
+4 -4
View File
@@ -19,8 +19,8 @@ import {
} from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getInputRuleColor,
getModeAccent,
getModeInputBackground,
getModeInputForeground,
getModeInputPlaceholder,
} from "../palette";
@@ -69,7 +69,7 @@ export function HomeView(props: {
const terminalTheme = useTerminalTheme();
const defaultFg = getDefaultForeground(terminalBg);
const accent = getModeAccent(session.uiMode, terminalTheme);
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
const inputRuleColor = getInputRuleColor(terminalBg);
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
const placeholder =
@@ -80,7 +80,7 @@ export function HomeView(props: {
props.autocomplete?.mode && props.autocomplete.options.length > 0;
const contentWidth = Math.min(width, HOME_VIEW_MAX_WIDTH);
const hasTypedInput = inputValue.trim().length > 0;
const inputStartX = Math.floor((width - contentWidth) / 2) + 4;
const inputStartX = Math.floor((width - contentWidth) / 2) + 2;
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
const trackedCursorX = hasTypedInput
@@ -116,7 +116,7 @@ export function HomeView(props: {
<box flexDirection="column" width={contentWidth} flexShrink={0}>
<InputBar
accent={accent}
inputBackground={inputBackground}
ruleColor={inputRuleColor}
inputForeground={inputForeground}
inputPlaceholder={inputPlaceholder}
placeholder={placeholder}
+42 -19
View File
@@ -29,7 +29,7 @@ import {
loadIndividualSubscriptionPlansFromProviderSettings,
} from "../../cline-account";
import {
buildClineModelEntries,
buildFeaturedModelEntries,
type ClineModelPickerEntry,
useClineRecommendedModels,
} from "../../components/model-selector/cline-model-picker";
@@ -58,6 +58,7 @@ import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
DEFAULT_THINKING_LEVEL_INDEX,
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
@@ -205,11 +206,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const modelList = useSearchableList(modelItems, createCustomModelItem);
// Cline featured model picker
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
const recommended = useClineRecommendedModels();
const clineEntries: ClineModelPickerEntry[] = useMemo(
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
[recommended.data],
() =>
recommended.data
? buildFeaturedModelEntries(activeProviderId, recommended.data)
: [],
[recommended.data, activeProviderId],
);
const [clineModelSelected, setClineModelSelected] = useState(0);
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
@@ -220,24 +224,43 @@ export function useOnboardingController(props: OnboardingControllerProps) {
>(undefined);
useEffect(() => {
getLocalProviderModels("cline")
.then(({ models }) => {
const ids = new Set<string>();
for (const m of models) {
// The featured picker serves both cline and cline-pass, so pool reasoning
// support and display names from both catalogs
void Promise.allSettled(
["cline", "cline-pass"].map((providerId) =>
getLocalProviderModels(providerId),
),
).then((results) => {
const ids = new Set<string>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const m of result.value.models) {
if (m.supportsReasoning) ids.add(m.id);
}
setClineModelReasoningIds(ids);
})
.catch(() => {});
resolveProviderConfig("cline")
.then((resolved) => {
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
})
.catch(() => {});
}
setClineModelReasoningIds(ids);
});
void Promise.allSettled(
["cline", "cline-pass"].map((providerId) =>
resolveProviderConfig(providerId),
),
).then((results) => {
const merged: Record<string, unknown> = {};
for (const result of results) {
if (result.status === "fulfilled" && result.value?.knownModels) {
Object.assign(merged, result.value.knownModels);
}
}
if (Object.keys(merged).length > 0) {
setClineKnownModels(merged);
}
});
}, []);
// Thinking level
const [thinkingSelected, setThinkingSelected] = useState(0);
const [thinkingSelected, setThinkingSelected] = useState(
DEFAULT_THINKING_LEVEL_INDEX,
);
const [selectedModelName, setSelectedModelName] = useState("");
const [selectedModelId, setSelectedModelId] = useState("");
const [selectedThinking, setSelectedThinking] = useState(false);
@@ -641,7 +664,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const entry = modelEntries.find((m) => m.id === modelId);
if (entry?.supportsReasoning) {
setSelectedModelName(entry.name);
setThinkingSelected(0);
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
setStep("thinking_level");
} else {
setStep("done");
@@ -691,7 +714,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setSelectedModelId(modelId);
if (clineModelReasoningIds.has(modelId)) {
setSelectedModelName(modelName);
setThinkingSelected(0);
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
setStep("thinking_level");
} else {
setStep("done");
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
expect(getOAuthProviderLabel("oca")).toBe("oca");
});
it("uses the featured Cline model picker only for the Cline provider", () => {
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
+6 -1
View File
@@ -30,6 +30,10 @@ export const THINKING_LEVELS: {
{ value: "xhigh", label: "Extra High", desc: "Maximum reasoning" },
];
export const DEFAULT_THINKING_LEVEL_INDEX = THINKING_LEVELS.findIndex(
(l) => l.value === "medium",
);
export interface MenuOption {
label: string;
value: string;
@@ -203,5 +207,6 @@ export function getOAuthProviderLabel(providerId: string): string {
}
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
return providerId === "cline";
// ClinePass uses the featured picker too, with Subscribed/Free sections
return providerId === "cline" || providerId === "cline-pass";
}
@@ -384,7 +384,7 @@ export function OnboardingCodexCliScreen(props: {
<text fg="yellow">Codex CLI was not found</text>
<text fg="gray">{props.status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg="cyan" selectable>
<text fg={palette.act} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
</box>
+9
View File
@@ -14,6 +14,15 @@ export type ChatCommandState = {
export type ForkSessionResult = {
forkedFromSessionId: string;
newSessionId: string;
/**
* Present when the source session had valid compaction state that was
* re-anchored onto the forked session, so the UI can surface why the
* next request is smaller than the canonical history.
*/
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
};
export type MuteCommandInput = {
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
const raw =
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const detail =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClinePassLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Switch to Cline usage-based billing",
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
});
+41
View File
@@ -1,10 +1,13 @@
import {
type ClineSubscriptionPlan,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
isClinePassLimitError,
isClinePassLimitMessage,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
@@ -24,6 +27,18 @@ export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
export function getCliClinePassLimitMessage(message: string): string {
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
const lines = [
"ClinePass limit reached",
detail,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
];
return lines.filter((line) => line.trim().length > 0).join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
);
}
export function getClinePassLimitDetailMessage(
error: unknown,
): string | undefined {
return extractClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
export function isClinePassLimitErrorMessage(error: unknown): boolean {
if (isClinePassLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClinePassLimitError" ||
isClinePassLimitMessage(error.message)
);
}
return typeof error === "string" && isClinePassLimitMessage(error);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (isClinePassLimitErrorMessage(error)) {
return getCliClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (error instanceof Error) {
return error.message;
}
+3 -3
View File
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
});
it("maps basic and off modes to core compaction config", () => {
const config = createConfig({ enabled: true, maxInputTokens: 123 });
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
applyCliCompactionMode(config, "basic");
expect(config.compaction).toEqual({
enabled: true,
strategy: "basic",
maxInputTokens: 123,
preserveRecentTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("basic");
applyCliCompactionMode(config, "off");
expect(config.compaction).toEqual({
enabled: false,
maxInputTokens: 123,
preserveRecentTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("off");
});
+63 -1
View File
@@ -1,19 +1,64 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleEvent, handleTeamEvent } from "./events";
import {
handleEvent,
handleTeamEvent,
resolveStatusNoticeLabel,
} from "./events";
import { setCurrentOutputMode } from "./output";
import type { Config } from "./types";
describe("resolveStatusNoticeLabel", () => {
it("maps compaction status reasons to stable labels", () => {
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "auto-compacting",
reason: "auto_compaction",
} as AgentEvent),
).toBe("auto-compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "manual",
reason: "manual_compaction",
} as AgentEvent),
).toBe("compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction-budget-adjusted",
reason: "compaction_budget_emergency",
} as AgentEvent),
).toBe("context budget adjusted");
});
});
describe("handleEvent text formatting", () => {
let output = "";
let errorOutput = "";
beforeEach(() => {
output = "";
errorOutput = "";
setCurrentOutputMode("text");
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
output += String(chunk);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
errorOutput += String(chunk);
return true;
});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
errorOutput += `${args.map(String).join(" ")}\n`;
});
});
it("adds a ⎿ before text that follows a tool block", () => {
@@ -160,6 +205,23 @@ describe("handleEvent text formatting", () => {
expect(output).toContain("── aborted (2 iterations) ──");
});
it("formats ClinePass limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("ClinePass limit reached");
expect(errorOutput).toContain("Switch to Cline usage-based billing");
expect(errorOutput).toContain("--provider cline");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+31 -3
View File
@@ -1,4 +1,9 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import {
formatCompactionDividerLabel,
parseCompactionNoticeMetadata,
} from "../tui/utils/compaction-status";
import { formatCliErrorMessage } from "./cline-pass-errors";
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
import {
c,
@@ -27,8 +32,31 @@ export function resolveStatusNoticeLabel(
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
if (event.reason === "auto_compaction") {
return "auto-compacting";
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (compaction) {
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
}
return resolveNonCompactionStatusLabel(event);
}
/**
* Label for a status notice already known not to be a compaction notice.
* Callers that have parsed the compaction metadata themselves use this to
* avoid re-parsing.
*/
export function resolveNonCompactionStatusLabel(
event: AgentEvent,
): string | undefined {
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
switch (event.reason) {
case "auto_compaction":
return "auto-compacting";
case "manual_compaction":
return "compacting";
case "compaction_budget_emergency":
return "context budget adjusted";
}
return event.message.trim() || undefined;
}
@@ -176,7 +204,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(event.error.message);
writeErr(formatCliErrorMessage(event.error));
}
break;
case "notice":
@@ -53,6 +53,37 @@ describe("shouldZeroClineFreeModelCost", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("zeros cost of free models selected on the cline-pass provider", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
// subscription (cline-pass/...) models are not in the free bucket
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "cline-pass/glm-5.1",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
+3 -1
View File
@@ -73,7 +73,9 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
// Free models are also selectable on ClinePass — they ride usage billing at $0
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
+8 -6
View File
@@ -42,11 +42,12 @@ export function getPersistedProviderApiKey(
* or endpoint config for the provider. Used by the picker to decide whether
* to offer "Use existing configuration?" before opening the configure dialog.
*
* Treats OAuth providers as configured when an access token is present; for
* everything else, any persisted API key, base URL, or model id counts. We
* don't enforce required fields here the runtime no longer pre-flights
* credentials, so a missing key only matters when the API call actually
* runs and the provider's own auth error is surfaced.
* Treats OAuth providers as configured when an access token or a manually
* saved API key is present (the /settings escape hatch for when OAuth isn't
* working); for everything else, any persisted API key, base URL, or model id
* counts. We don't enforce required fields here the runtime no longer
* pre-flights credentials, so a missing key only matters when the API call
* actually runs and the provider's own auth error is surfaced.
*/
export function isProviderConfigured(
providerId: string,
@@ -54,7 +55,8 @@ export function isProviderConfigured(
): boolean {
if (!settings) return false;
if (isOAuthProvider(providerId)) {
return Boolean(settings.auth?.accessToken?.trim());
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
return Boolean(getPersistedProviderApiKey(providerId, settings));
}
if (getPersistedProviderApiKey(providerId, settings)) return true;
if (settings.baseUrl?.trim()) return true;
@@ -11,6 +11,7 @@ import {
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
@@ -103,7 +104,9 @@ export async function handleDesktopCommand(
): Promise<unknown> {
if (command === "list_provider_catalog") {
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
}
if (command === "list_provider_models") {
const provider = String(args?.provider ?? "").trim();
@@ -165,6 +168,11 @@ export async function handleDesktopCommand(
providerId,
openExternalUrl,
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(providerSettingsManager, providerId, {
tokenSource: "oauth",
});
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
+9 -1
View File
@@ -5,6 +5,7 @@ import {
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
saveLocalProviderSettings,
} from "@cline/core";
@@ -99,7 +100,9 @@ export async function sendProviderCatalog(
peer: BrowserPeer,
): Promise<void> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
ctx.send(peer, {
type: "provider_catalog",
providers: payload.providers,
@@ -138,6 +141,11 @@ export async function runProviderOAuthLogin(
normalized,
openExternalUrl,
);
if (saved.provider !== normalized) {
markLocalProviderEnabled(providerSettingsManager, normalized, {
tokenSource: "oauth",
});
}
ctx.send(peer, {
type: "provider_oauth_login_done",
providerId: normalized,
+9 -2
View File
@@ -1,3 +1,4 @@
import { formatDisplayUserInput } from "@cline/shared";
import type {
WebviewActionSessionSummary,
WebviewChatMessage,
@@ -213,10 +214,16 @@ export function mapHistoryToWebviewMessages(
const currentToolBlockIndexes = new Map<string, number>();
let reasoningRedacted = false;
// Persisted user text arrives raw, including runtime-generated
// <user_input>/<mode_notice> wrappers -- format at this display
// boundary so the webview never renders them.
const displayText = (text: string): string =>
role === "user" ? formatDisplayUserInput(text) : text;
const contentParts = historyContentParts(record.content);
if (contentParts.length === 0) {
const text = stringifyContent(record.content ?? record.text ?? record);
pushTextBlock(blocks, textParts, messageKey, 0, text);
pushTextBlock(blocks, textParts, messageKey, 0, displayText(text));
}
for (const [partIndex, part] of contentParts.entries()) {
@@ -227,7 +234,7 @@ export function mapHistoryToWebviewMessages(
textParts,
messageKey,
partIndex,
asString(part.text) ?? asString(part.content) ?? "",
displayText(asString(part.text) ?? asString(part.content) ?? ""),
);
continue;
}
@@ -20,10 +20,13 @@ import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
// react-markdown/streamdown pass the hast `Element` here, whose
// `properties` is a broad `Record`. Keep this assignable from that type
// (rather than a narrow `{ metastring?: string }`) so the component stays
// compatible with `Components` regardless of how strict the resolved
// hast/streamdown types are; the metastring value is validated at read time.
node?: {
properties?: {
metastring?: string;
};
properties?: Record<string, unknown>;
};
};
@@ -67,7 +70,8 @@ const MarkdownCode = ({
);
}
const meta = node?.properties?.metastring;
const metaValue = node?.properties?.metastring;
const meta = typeof metaValue === "string" ? metaValue : undefined;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
@@ -258,8 +258,8 @@ export function SettingsView({
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -386,7 +386,7 @@ export function SettingsView({
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
usesOAuth(selectedProvider)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
@@ -46,6 +46,7 @@ export interface Provider {
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
capabilities?: string[];
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
+52
View File
@@ -13,8 +13,60 @@ From `apps/examples/desktop-app/`:
- `bun run build:sidecar` - build the Bun sidecar bundle
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
- `bun run build:binary` - build desktop binary
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
package. Other Cline web surfaces can take only its tokens or opt into the
Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Shareable Desktop Packages
Tauri desktop bundles are OS-specific, so build each package on the target OS:
- macOS: `bun run package:desktop:mac`
- Windows: `bun run package:desktop:windows`
- Linux: `bun run package:desktop:linux`
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
### macOS signing & notarization, step by step
One-time keychain setup:
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
Per-build:
```bash
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
export APPLE_API_ISSUER="<issuer UUID>"
bun run package:desktop:mac
```
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 210 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
## Runtime Overview
Startup flow:
+15 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.0",
"version": "0.0.1",
"private": true,
"scripts": {
"dev:web": "next dev webview -p 3125 --turbo",
@@ -10,6 +10,11 @@
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -19,7 +24,9 @@
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@fontsource-variable/geist": "^5.2.8",
"@cline/ui": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource/azeret-mono": "^5.2.9",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
@@ -48,6 +55,9 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
@@ -58,7 +68,6 @@
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"marked": "^17.0.3",
"next": "16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -66,11 +75,11 @@
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"sonner": "^1.7.1",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
@@ -80,6 +89,7 @@
"@tailwindcss/postcss": "^4.2.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"jsdom": "^26.0.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
@@ -0,0 +1,311 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
type DesktopPlatform = "mac" | "windows" | "linux";
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
const VALUE_FLAGS = new Set(["--platform", "--target"]);
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
const APP_NAME = "Cline Code";
const APP_ROOT = path.resolve(import.meta.dir, "..");
const BUNDLE_ROOT = path.join(
APP_ROOT,
"src-tauri",
"target",
"release",
"bundle",
);
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
process.chdir(APP_ROOT);
const validateArgs = (): void => {
const args = process.argv.slice(2);
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for ${arg}`);
}
index += 1;
continue;
}
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
continue;
}
if (arg.startsWith("--")) {
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
throw new Error(
suggestion
? `unknown option ${arg}. Did you mean ${suggestion}?`
: `unknown option ${arg}`,
);
}
throw new Error(`unexpected argument ${arg}`);
}
};
const getArgValue = (name: string): string | undefined => {
const prefix = `${name}=`;
const inline = process.argv.find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = process.argv.indexOf(name);
if (index >= 0) {
return process.argv[index + 1];
}
return undefined;
};
const hasArg = (name: string): boolean => process.argv.includes(name);
const hostPlatform = (): DesktopPlatform => {
if (process.platform === "darwin") {
return "mac";
}
if (process.platform === "win32") {
return "windows";
}
if (process.platform === "linux") {
return "linux";
}
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
};
const resolveRequestedPlatform = (): DesktopPlatform => {
const platform =
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
if (platform === "current") {
return hostPlatform();
}
if (platform === "mac" || platform === "windows" || platform === "linux") {
return platform;
}
throw new Error(
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
);
};
const sanitizeName = (value: string): string =>
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
const packageVersion = async (): Promise<string> => {
const packageJson = await Bun.file(
path.join(APP_ROOT, "package.json"),
).json();
return String(packageJson.version ?? "0.0.0");
};
const macDistributionCredentialsConfigured = (): boolean => {
const hasCertificate = Boolean(
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
);
const hasAppleIdNotarization = Boolean(
process.env.APPLE_ID &&
process.env.APPLE_PASSWORD &&
process.env.APPLE_TEAM_ID,
);
const hasApiKeyNotarization = Boolean(
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER,
);
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
};
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
const host = hostPlatform();
if (platform !== host) {
throw new Error(
[
`cannot build ${platform} desktop bundles from ${host}.`,
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
].join("\n"),
);
}
};
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
if (hostPlatform() !== "mac") {
return;
}
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
return;
}
throw new Error(
[
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
].join("\n"),
);
};
const walkFiles = (root: string): string[] => {
if (!existsSync(root)) {
return [];
}
const paths: string[] = [];
for (const entry of readdirSync(root)) {
const fullPath = path.join(root, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
paths.push(...walkFiles(fullPath));
continue;
}
paths.push(fullPath);
}
return paths;
};
const copyArtifact = (source: string, outputName: string): string => {
const destination = path.join(PACKAGE_ROOT, outputName);
rmSync(destination, { force: true, recursive: true });
cpSync(source, destination, { recursive: true });
return destination;
};
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --force --deep --sign - ${appPath}`;
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const verifySignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`spctl --assess --type execute --verbose ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const collectMacArtifacts = async (
version: string,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
if (!existsSync(appPath)) {
throw new Error(`macOS app bundle was not created at ${appPath}`);
}
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
console.warn(
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
);
await signUnsignedMacApp(appPath);
} else {
await verifySignedMacApp(appPath);
}
const arch = process.arch === "arm64" ? "arm64" : "x64";
const suffix =
allowUnsignedMac && !macDistributionCredentialsConfigured()
? "-local-unsigned"
: "";
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
const zipPath = path.join(PACKAGE_ROOT, zipName);
rmSync(zipPath, { force: true });
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
const artifacts = [zipPath];
if (!suffix) {
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
(file) => file.endsWith(".dmg"),
)) {
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
}
}
return artifacts;
};
const collectWindowsArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
.map((file) => copyArtifact(file, path.basename(file)));
const collectLinuxArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter(
(file) =>
file.endsWith(".AppImage") ||
file.endsWith(".deb") ||
file.endsWith(".rpm"),
)
.map((file) => copyArtifact(file, path.basename(file)));
const collectArtifacts = async (
platform: DesktopPlatform,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const version = await packageVersion();
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
mkdirSync(PACKAGE_ROOT, { recursive: true });
if (platform === "mac") {
return collectMacArtifacts(version, allowUnsignedMac);
}
if (platform === "windows") {
return collectWindowsArtifacts();
}
return collectLinuxArtifacts();
};
const main = async () => {
validateArgs();
const platform = resolveRequestedPlatform();
const allowUnsignedMac =
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
const skipBuild = hasArg("--skip-build");
assertCanBuildPlatform(platform);
if (platform === "mac") {
assertMacDistributionReady(allowUnsignedMac);
}
if (!skipBuild) {
await $`bun run build:binary`;
}
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
if (artifacts.length === 0) {
throw new Error(
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
);
}
console.log(`Packaged ${platform} desktop artifacts:`);
for (const artifact of artifacts) {
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
}
};
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
@@ -0,0 +1,244 @@
import { describe, expect, it, vi } from "vitest";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
prewarmWorkspaceMetadata,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import type { SidecarContext } from "./types";
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
});
expect(Object.hasOwn(update, "thinking")).toBe(false);
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
it("clears reasoning settings when thinking is explicitly disabled", () => {
expect(
buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: false,
}),
).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: false,
reasoningEffort: null,
thinkingBudgetTokens: null,
});
});
it("updates explicit reasoning settings without clearing omitted settings", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
reasoningEffort: "high",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
});
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
});
describe("shouldUpdateSessionConnection", () => {
it("skips the redundant connection update on the first send", () => {
const config = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
});
it("updates the connection when the selected reasoning level changes", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "low",
};
expect(
shouldUpdateSessionConnection(current, {
...current,
reasoningEffort: "high",
}),
).toBe(true);
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
function createContext(options?: {
attachedViaHub?: boolean;
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
text: "done",
finishReason: "completed",
messages: [],
}));
const sessionId = "session-connection-test";
const ctx = {
liveSessions: new Map([
[
sessionId,
{
config: options?.config ?? baseConfig,
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
attachedViaHub: options?.attachedViaHub ?? false,
},
],
]),
sessionManager: { send, updateSessionConnection },
} as unknown as SidecarContext;
return { ctx, send, sessionId, updateSessionConnection };
}
it("skips an identical update for a locally-created session", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledTimes(1);
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("refreshes hub-attached sessions even when the cached config matches", async () => {
const { ctx, sessionId, updateSessionConnection } = createContext({
attachedViaHub: true,
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
});
});
describe("workspace metadata prewarming", () => {
it("reuses one in-flight scan and consumes it only once", async () => {
let resolveFirst: ((value: string) => void) | undefined;
const firstResult = new Promise<string>((resolve) => {
resolveFirst = resolve;
});
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockImplementationOnce(async () => await firstResult)
.mockResolvedValueOnce("fresh metadata");
const cwd = "/tmp/cline-desktop-prewarm-reuse";
prewarmWorkspaceMetadata(cwd, load);
const consumed = consumeWorkspaceMetadata(cwd, load);
expect(load).toHaveBeenCalledTimes(1);
resolveFirst?.("prewarmed metadata");
await expect(consumed).resolves.toBe("prewarmed metadata");
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"fresh metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("evicts failed scans so the next session can retry", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockRejectedValueOnce(new Error("git unavailable"))
.mockResolvedValueOnce("recovered metadata");
const cwd = "/tmp/cline-desktop-prewarm-retry";
prewarmWorkspaceMetadata(cwd, load);
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
"git unavailable",
);
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"recovered metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("keeps different workspaces in separate single-flight entries", () => {
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
expect(load).toHaveBeenCalledTimes(2);
});
it("refreshes a prewarm that is older than the startup window", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockResolvedValueOnce("startup metadata")
.mockResolvedValueOnce("current metadata");
const cwd = "/tmp/cline-desktop-prewarm-expired";
prewarmWorkspaceMetadata(cwd, load, () => 0);
await expect(
consumeWorkspaceMetadata(
cwd,
load,
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
),
).resolves.toBe("current metadata");
expect(load).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,8 @@
import { existsSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
@@ -20,6 +22,73 @@ import type {
SidecarContext,
} from "./types";
type SessionConnectionUpdate = Parameters<
ClineCore["updateSessionConnection"]
>[1];
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
type WorkspaceMetadataCacheEntry = {
createdAt: number;
promise: Promise<string>;
};
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
const workspaceMetadataPromises = new Map<
string,
WorkspaceMetadataCacheEntry
>();
function getWorkspaceMetadataPromise(
cwd: string,
load: WorkspaceMetadataLoader,
now: () => number,
): { key: string; promise: Promise<string> } {
const key = resolve(cwd);
const existing = workspaceMetadataPromises.get(key);
const createdAt = now();
if (
existing &&
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
) {
return { key, promise: existing.promise };
}
const promise = load(key);
workspaceMetadataPromises.set(key, { createdAt, promise });
void promise.catch(() => {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
});
return { key, promise };
}
export function prewarmWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): void {
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
}
export async function consumeWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): Promise<string> {
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
try {
return await promise;
} finally {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
}
}
export function refreshWorkspaceMetadata(cwd: string): void {
workspaceMetadataPromises.delete(resolve(cwd));
prewarmWorkspaceMetadata(cwd);
}
// ---------------------------------------------------------------------------
// Session data helpers
// ---------------------------------------------------------------------------
@@ -103,7 +172,40 @@ function isoTimestampToMs(
return Number.isFinite(parsed) ? parsed : undefined;
}
function readReasoningEffort(
value: unknown,
): "low" | "medium" | "high" | "xhigh" | undefined {
if (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
) {
return value;
}
return undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
return undefined;
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
thinking === false
? undefined
: readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens =
thinking === false
? undefined
: readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return {
sessionId: config.sessionId ?? config.session_id,
providerId: config.provider ?? config.providerId ?? "",
@@ -125,6 +227,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
config.enableAgentTeams ??
config.enable_teams ??
false,
...(thinking !== undefined ? { thinking } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
teamName: config.teamName ?? config.team_name,
missionLogIntervalSteps:
config.missionStepInterval ?? config.missionLogIntervalSteps,
@@ -136,6 +241,58 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
};
}
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
// into typed fields; the thinking/reasoning transition rules live in the
// shared @cline/core builder.
const providerId = String(config.provider ?? config.providerId ?? "").trim();
const modelId = String(config.model ?? config.modelId ?? "").trim();
const rawApiKey =
typeof config.apiKey === "string"
? config.apiKey.trim()
: typeof config.api_key === "string"
? config.api_key.trim()
: undefined;
const baseUrl =
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return buildConnectionUpdate({
...(providerId ? { providerId } : {}),
...(modelId ? { modelId } : {}),
...(rawApiKey ? { apiKey: rawApiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
...(config.headers && typeof config.headers === "object"
? { headers: config.headers as Record<string, string> }
: {}),
...(config.providerConfig && typeof config.providerConfig === "object"
? {
providerConfig:
config.providerConfig as SessionConnectionUpdate["providerConfig"],
}
: {}),
...(typeof config.thinking === "boolean"
? { thinking: config.thinking }
: {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
});
}
export function shouldUpdateSessionConnection(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
return !isDeepStrictEqual(
buildSessionConnectionUpdate(currentConfig),
buildSessionConnectionUpdate(nextConfig),
);
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -149,7 +306,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
: config.mode === "plan"
? "plan"
: "act";
const metadata = await buildWorkspaceMetadata(cwd);
const metadata = await consumeWorkspaceMetadata(cwd);
const inlineRules =
typeof config.rules === "string" && config.rules.trim().length > 0
? config.rules
@@ -363,6 +520,19 @@ async function handleSend(
if (!prompt) throw new Error("prompt is required");
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, request.config)
) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
}
if (session) {
session.config = { ...session.config, ...request.config };
}
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
+95 -7
View File
@@ -1,5 +1,11 @@
import { execFileSync, spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type {
ClineAccountActionRequest,
@@ -25,6 +31,7 @@ import {
listLocalProviders,
listPluginTools,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
readGlobalSettings,
@@ -35,13 +42,26 @@ import {
SqliteSessionStore,
saveLocalProviderSettings,
sendHubCommand,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
findArtifactUnderDir,
readSessionManifest,
@@ -945,7 +965,7 @@ export async function handleCommand(
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await listLocalProviders(manager);
return await listLocalProviders(manager, { isClinePassEnabled: true });
}
if (command === "list_provider_models") {
const manager = new ProviderSettingsManager();
@@ -1025,12 +1045,45 @@ export async function handleCommand(
spawned.unref();
},
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
};
}
// ── Global settings ────────────────────────────────────────────────
if (command === "get_global_settings") {
return readGlobalSettings();
}
if (command === "set_telemetry_opt_out") {
if (typeof args?.telemetry_opt_out !== "boolean") {
throw new Error("telemetry_opt_out must be a boolean");
}
setTelemetryOptOutGlobally(args.telemetry_opt_out);
return readGlobalSettings();
}
if (command === "set_auto_update_enabled") {
if (typeof args?.auto_update_enabled !== "boolean") {
throw new Error("auto_update_enabled must be a boolean");
}
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
}
if (command === "start_connector_channel") {
return await startConnectorChannel(ctx.workspaceRoot, args);
}
if (command === "stop_connector_channel") {
return await stopConnectorChannel(ctx.workspaceRoot, args);
}
// ── MCP server management ─────────────────────────────────────────
if (command === "list_mcp_servers") {
return readMcpServersResponse();
@@ -1116,10 +1169,13 @@ export async function handleCommand(
// ── Git operations ─────────────────────────────────────────────────
if (command === "get_git_branch") {
const branches = listGitBranches(
ctx,
typeof args?.cwd === "string" ? args.cwd : undefined,
);
const cwd =
typeof args?.cwd === "string" && args.cwd.trim()
? args.cwd.trim()
: ctx.workspaceRoot;
const branches = listGitBranches(ctx, cwd);
const { prewarmWorkspaceMetadata } = await import("./chat-session");
prewarmWorkspaceMetadata(cwd);
return { branch: branches.current };
}
if (command === "list_git_branches") {
@@ -1132,11 +1188,14 @@ export async function handleCommand(
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
const branch = String(args?.branch ?? "").trim();
if (!branch) throw new Error("branch is required");
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
execFileSync("git", ["checkout", branch], {
cwd: cwd?.trim() || ctx.workspaceRoot,
cwd: targetCwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const { refreshWorkspaceMetadata } = await import("./chat-session");
refreshWorkspaceMetadata(targetCwd);
return { branch };
}
@@ -1156,6 +1215,26 @@ export async function handleCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(ctx.workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(ctx.workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, {
workspaceRoot: ctx.workspaceRoot,
});
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) {
@@ -1185,6 +1264,15 @@ export async function handleCommand(
}
// ── Native OS commands ────────────────────────────────────────────
if (command === "validate_workspace_directory") {
const workspacePath = String(args?.path ?? "").trim();
if (!workspacePath) return { valid: false };
try {
return { valid: statSync(workspacePath).isDirectory() };
} catch {
return { valid: false };
}
}
if (command === "pick_workspace_directory") {
return pickWorkspaceDirectory();
}
@@ -0,0 +1,307 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename, join, normalize } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
import type { JsonRecord } from "./types";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: {
flag: string;
equals?: string;
notEquals?: string;
};
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type WebviewConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type WebviewConnectorChannelsResponse = {
available: WebviewConnectorChannel[];
active: ReturnType<typeof listActiveConnectors>;
};
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function asRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
workspaceRoot: string,
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath =
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
type: platform.type,
hint: platform.hint,
fields: platform.fields.map((field) => ({
flag: field.flag,
label: field.label,
placeholder: field.placeholder,
required: field.required,
help: field.help,
initialValue: field.initialValue,
options: field.options,
includeWhen: field.includeWhen,
})),
security: platform.security
? {
prompt: platform.security.prompt,
fields: platform.security.fields.map((field) => ({
key: field.key,
label: field.label,
placeholder: field.placeholder,
help: field.help,
requiredMessage: field.requiredMessage,
})),
}
: undefined,
}));
return { available, active: listActiveConnectors() };
}
async function runCliConnectCommand(
workspaceRoot: string,
args: string[],
): Promise<{
code: number;
stdout: string;
stderr: string;
}> {
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const code = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (exitCode) => resolve(exitCode ?? 0));
});
return { code, stdout, stderr };
}
async function waitForConnectorState(
predicate: () => boolean,
timeoutMs = 5_000,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`connector did not reach expected state within ${timeoutMs}ms`,
);
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
const rawValue = values[field.flag];
if (typeof rawValue === "string") {
fieldValues[field.flag] = rawValue.trim();
} else if (field.initialValue) {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
}
return cliArgs;
}
export async function startConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
export async function stopConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}

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