Compare commits

..

56 Commits

Author SHA1 Message Date
Robin Newhouse 8333e1e86b Align basic compaction budget expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse 25fa84c9e6 Align basic sanitization image expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse da35f08f03 Clarify basic projection budget logging 2026-07-07 13:23:54 -07:00
Robin Newhouse 02bc1bc432 Budget basic compaction projections 2026-07-07 13:23:54 -07:00
Robin Newhouse 49c0d1b6a3 Align agentic file ops with projected input 2026-07-07 13:23:54 -07:00
Robin Newhouse 92dc5dfed3 Align agentic compaction test tool result 2026-07-07 13:23:53 -07:00
Robin Newhouse ce85e49c7b Harden agentic summary budget fallback 2026-07-07 13:23:53 -07:00
Robin Newhouse dfecadbcbd Budget agentic compaction summary input 2026-07-07 13:23:53 -07:00
Robin Newhouse 519a22c5d5 Fix budget projection action accounting 2026-07-07 13:23:53 -07:00
Robin Newhouse fbdfa77bb9 fix(core): narrow compaction protected tail 2026-07-07 13:23:53 -07:00
Robin Newhouse 5ad8d33977 Clean up budget projection fixture indentation 2026-07-07 13:23:53 -07:00
Robin Newhouse 5226b107ba Align budget projection test tool results 2026-07-07 13:23:53 -07:00
Robin Newhouse 26f015fbd1 Recompute protected tail after thinking pruning 2026-07-07 13:23:52 -07:00
Robin Newhouse bdce31deea Drop provider-native blocks during budget projection 2026-07-07 13:23:52 -07:00
Robin Newhouse 406674d27f Fix budget projection truncation accounting 2026-07-07 13:23:52 -07:00
Robin Newhouse 24303ab0cb Add pure compaction budget projection engine 2026-07-07 13:23:52 -07:00
Robin Newhouse f48ba92357 Tighten dropped block action paths 2026-07-07 13:23:52 -07:00
Robin Newhouse 3693d2f867 Tighten budget projection contract types 2026-07-07 13:23:52 -07:00
Robin Newhouse 86aca36d03 Add compaction budget projection contract 2026-07-07 13:23:52 -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
Saoud Rizwan 25ef0939cc chore(cli): release v3.0.37 2026-07-03 19:31:47 -07:00
Saoud Rizwan 4f770011e9 chore(sdk): release v0.0.56 2026-07-03 19:11:03 -07:00
Saoud Rizwan a1d69fee6f fix(llms): stop AI SDK from rejecting malformed tool calls before flexible tool executors can handle them (#12061)
* fix(llms): stop rejecting malformed tool calls before tools can handle them

Weak models emit tool calls with schema mismatches (bare string for a
string[] arg) or unparsable JSON. The AI SDK adapter rejected both
before execution, so the lenient union schemas in the core tool
executors never ran. Drop the strict validate callback (tools own input
validation) and add experimental_repairToolCall backed by the shared
jsonrepair parser for arguments that fail JSON parsing.

* docs(llms): fix stale comment referencing removed validate callback
2026-07-03 18:55:31 -07:00
Saoud Rizwan 3575b38122 fix(shared): stop deleting mode_notice from outbound prompts (#12058)
* fix(shared): stop deleting mode_notice from outbound prompts

The mode-switch notice from #12057 never reached the model:
prepareTurnInput sanitizes every outbound prompt with normalizeUserInput
before wrapping it, and #12057 put the mode_notice strip inside
normalizeUserInput -- so the host deleted the notice on every send. The
transcript confirms it: messages sent after a toggle carry the
user_input wrapper but no notice, and models asked about it confabulate
having seen one because the system prompt describes the tag.

Move the strip into a dedicated stripModeNotices() applied only at
display boundaries: formatDisplayUserInput (TUI hydration, title
inference), deriveTitleFromPrompt (session titles), and the TUI queued
prompt echo. normalizeUserInput now preserves notices, with a
regression test pinning the outbound behavior. Side benefit: notices
survive the message-builder history normalization and the pending
prompt queue, so queued sends deliver them too.

* docs(shared): correct formatModeSwitchNotice JSDoc after strip relocation

* refactor(shared): generalize notice stripping to stripTagElements

stripModeNotices becomes a thin policy wrapper (DISPLAY_HIDDEN_TAGS owns
the what-to-hide list in one place) over a generic stripTagElements that
removes whole elements for any tag list -- the remove-element counterpart
to xmlTagsRemoval. Call sites at display boundaries now carry comments
explaining why stripping happens there and not in normalizeUserInput,
which also sanitizes model-bound prompts.

* revert(shared): drop stripTagElements generalization, keep simple stripModeNotices

The generic tag stripper added API surface without a second use case;
stripModeNotices goes back to the direct implementation. The display-vs-
model call-site comments from the same commit stay.
2026-07-03 15:45:34 -07:00
Saoud Rizwan b5468e1227 feat(cli): make plan/act mode switches visible to the model (#12057)
* feat(cli): make plan/act mode switches visible to the model

The mode signal already rides on every user message via the
<user_input mode="..."> wrapper, but nothing ever told the model what
the attribute means, and a manual plan/act toggle produced no inline
signal at all -- only an invisible system prompt swap the model cannot
diff. Two additions:

- The CLI system prompt now explains the mode attribute (both modes,
  since after a switch the transcript still contains messages tagged
  with the other mode) and that the newest message's mode governs.
- A user-initiated toggle stamps the next user message with a
  <mode_notice> block marking the switch, e.g. "The user switched from
  act mode to plan mode before sending this message." Round trips that
  return to the mode the model last saw cancel out. The model-initiated
  switch_to_act_mode path is excluded: its continuation prompt already
  announces the switch.

The notice vocabulary lives in @cline/shared next to the user_input
wrapper it extends, and normalizeUserInput hides the whole element from
transcript display the same structural way it strips the wrapper tags.

* fix(shared): strip mode_notice elements without polynomial regex

CodeQL flagged the lazy dot-all pattern (js/polynomial-redos): with the
global flag, every unmatched opening tag re-scans to the end of the
string, which is quadratic on adversarial transcript content. Replace
it with an indexOf-based splice that removes matched elements in linear
time and leaves unclosed tags intact, with a regression test on 50k
repeated open tags.
2026-07-03 15:05:22 -07:00
Saoud Rizwan 10d1c41b7a fix(cli): prevent empty session from racing a mode-change restart (#12056)
restartWithMessages cleared startupPromise and tore down the active
session before the replacement registered, leaving a window with no
active session and no startup in flight. A message submitted in that
window (e.g. typed right after a plan/act Tab toggle) made ensureReady
boot a blank fresh session, which then won the active slot over the
restarted session carrying the conversation history -- the model
responded as if the conversation had just started.

Publish the restart itself as the in-flight startupPromise so any
concurrent ensureReady waits for the restart instead of booting an
empty session. The barrier is cleared once the restart settles,
keeping failed restarts retryable by the next ensureReady.
2026-07-03 14:10:12 -07:00
Saoud Rizwan b823358867 chore(cli): release v3.0.36 2026-07-03 13:39:40 -07:00
Saoud Rizwan b876945c6d fix(cli): end plan-mode run on switch_to_act_mode and auto-continue with act tools (#12054)
* fix(cli): end plan-mode run on switch_to_act_mode and auto-continue with act tools

The CLI's switch_to_act_mode tool only queued the mode change; it was
applied after the whole turn finished. The model kept running the rest
of the turn with plan-mode tools (no editor) despite the tool result
claiming it now had edit access, so it fell back to editing files via
run_commands (sed/heredocs).

Mirror the VS Code extension's approach: the switch tool now completes
the run (lifecycle.completesRun), the pending mode change rebuilds the
session with act-mode tools, and a canned continuation prompt resumes
the approved plan automatically. Pending mode changes are tagged with
their source (tool vs UI toggle) so a Tab toggle racing a natural turn
completion can never auto-start plan execution the user did not
approve. The synthetic continuation prompt is hidden from transcript
hydration, and the plan-mode prompt/tool description now warn that
switching immediately starts execution.

* refactor(cli): show act-mode continuation prompt on resume instead of filtering it

Displaying the synthetic user message honestly beats exact-string
matching at the display layer, which was brittle and did not cover
other transcript consumers anyway. The live TUI still never echoes it;
it only appears as a user bubble when resuming a session. A
synthetic-message marker plumbed through SendSessionInput is the
principled follow-up if hiding it becomes worth the SDK surface
change.

* Revert "refactor(cli): show act-mode continuation prompt on resume instead of filtering it"

This reverts commit 969a24f9c9.
2026-07-03 13:10:29 -07:00
Saoud Rizwan 453cdea040 chore(cli): release v3.0.35 2026-07-03 10:26:32 -07:00
Saoud Rizwan 091eccdfe2 test: update GLM 5.2 context window assertions for refreshed catalog 2026-07-03 10:13:23 -07:00
Saoud Rizwan a2a46ae600 chore(sdk): release v0.0.55 2026-07-03 09:56:58 -07:00
Saoud Rizwan 82c9e77de2 style: apply formatter to pre-existing drift 2026-07-03 09:56:52 -07:00
Robin Newhouse dfd0e022a4 Add VS Code SDK compaction strategy setting (#11892)
* Add VS Code SDK compaction strategy setting

* Move compaction strategy setting into SDK

* Preserve stub global settings on compaction update

* Keep ApiProvider settings as proto strings

* Address compaction strategy review feedback
2026-07-02 23:58:47 -07:00
Robin Newhouse f0ec6a35bb fix: advertise run commands as shell strings (#12038) 2026-07-02 21:44:25 -07:00
Morgan Carr c09d54f5a2 fix(cli): format structured commands in history export (#12023)
Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-07-01 15:53:27 -07:00
Tomás Barreiro 984d70a351 Add the subscription promo code when linking to the dashboard subscription page (#12019)
* Add the subscription promo code when linking to the dashboard page

* revert tests
2026-07-02 00:47:30 +02:00
Bee bbe7b6fd49 fix(hub): hydrate tool results in session message mapping (#12011)
* fix(hub): hydrate tool results in session message mapping

Map historical tool call/use and tool result blocks into webview tool events, including same-message results and following user result messages. Add tests to verify hydrated outputs and block ordering so restored sessions render completed tool interactions correctly.

* feedback
2026-07-01 14:28:13 -07:00
Bee be97d951fa fix: first-prompt truncation (#12022)
* Fix basic compaction first-prompt truncation

Issue: shallow sessions on high-output models such as OpenRouter MiniMax M3 could auto-compact immediately and reduce the initial task prompt to only the leading <user_input> wrapper. Harbor still passed the full task into Cline and session metadata retained it, but the model conversation could receive a truncated first message and respond that the request was empty or cut off.

Root cause: the output-runway target used maxInputTokens - maxTokens for every basic compaction. For MiniMax M3, maxTokens is nearly maxInputTokens, producing a tiny target. Basic compaction then used its last-resort first-user truncation path, and raw messages still contain the user_input envelope, so prefix truncation preserved the wrapper instead of the actionable task.

Fix: only use the output-runway target after the transcript has at least five user-assistant pairs, so early/shallow tasks use the normal trigger-based target. Also prevent first-user truncation unless that first user message alone exceeds the trigger budget, preserving normal first-turn prompts while still allowing genuinely oversized prompts to be reduced. Added regression tests for the MiniMax-style shallow prompt case and the oversized-first-prompt escape hatch.

* Fix compaction budget for huge-output models

Avoid collapsing context-derived input budgets when a model reports an output limit nearly equal to its context window, such as MiniMax M3. In those cases, treating context-output as the input budget causes auto-compaction to trigger on normal-sized prompts.

Only use contextWindow - maxTokens when the derived value remains at least half of the context window. Also simplify long-conversation basic compaction targeting to maxInputTokens * 0.5 instead of applying the default target ratio to maxInputTokens - maxTokens.

Adds regression coverage for MiniMax-style context-only metadata so an 18k-token prompt does not compact against an incorrectly collapsed 12k input budget.

* Guard compaction estimator against cumulative metrics

* Address basic compaction target review comments
2026-07-01 13:51:50 -07:00
Robin Newhouse c331a8f4b6 fix(core): use curated default for legacy provider migration (#12030) 2026-07-01 12:42:42 -07:00
Ara f180f1584d Add first-request failure telemetry (#11852)
* fix(vscode): capture first request failure telemetry

* Fix provider failure telemetry review feedback

* test(vscode): avoid extension host mock matchers

* fix(vscode): use session metadata for provider failure telemetry

* test(vscode): document pre-session auth telemetry skip

* fix(vscode): use turn-scoped provider failure gate

* fix(vscode): include cline pass in auth failure checks

* refactor(vscode): keep provider failure turn count in gate
2026-07-01 10:23:36 -07:00
Ara 9197d15abf feat(vscode): recognize Tencent TokenHub provider (#12028) 2026-07-01 09:58:45 -07:00
Ara 6c0d5c97b1 feat(llms): add Tencent TokenHub provider (#12014) 2026-07-01 09:58:10 -07:00
Dominic Cooney 9a8be88e85 fix(protos): self-heal protoc download when bun skips grpc-tools postinstall (#12003) 2026-07-01 08:31:04 +09:00
Ara 60f4a482ca Add onboarding intent telemetry (#11848)
* Add onboarding intent telemetry

* Track prompt submit intent from chat UI
2026-06-30 14:19:51 -07:00
202 changed files with 9497 additions and 4231 deletions
@@ -1,6 +1,9 @@
name: ext-vscode-publish-nightly
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
+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 |
+34
View File
@@ -1,5 +1,39 @@
# Cline CLI Changelog
## 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
- Plan/act mode switches are now visible to the model, so it knows when you change modes mid-session
- Fixed plan/act mode notices being dropped from prompts sent to the model
- Fixed a race where switching modes in an empty session could trigger an unexpected restart
## 3.0.36
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
## 3.0.35
- ClinePass is now enabled for all CLI users
- Recover missing interactive sessions when reading messages
- Format structured commands in history export
- Add the subscription promo code when linking to the dashboard subscription page
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
- Advertise run commands as shell strings (from SDK v0.0.55)
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
## 3.0.34
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.34",
"version": "3.0.38",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+39
View File
@@ -313,6 +313,45 @@ describe("runHistoryExport", () => {
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
});
it("exports run_commands history with structured command objects", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const outputPath = join(tempDir, "export.html");
const artifact = {
version: 1,
updated_at: "2026-04-22T17:42:10.123Z",
sessionId: "sess_1",
messages: [
{
id: "m1",
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "run_commands",
input: {
commands: [{ command: "cmd", args: ["/c", "dir"] }],
},
},
],
},
],
} satisfies NonNullable<
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
>;
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const code = await runHistoryExport("sess_1", outputPath, "text", io);
expect(code).toBe(0);
expect(io.writeErr).not.toHaveBeenCalled();
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
});
it("fails when the session artifact is missing", async () => {
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
const io = {
@@ -126,7 +126,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("falls back to legacy contextWindow for manual compaction", async () => {
@@ -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",
);
});
+25 -6
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,7 +54,12 @@ 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 ??
@@ -81,8 +88,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,7 +100,7 @@ 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: {
@@ -103,8 +113,17 @@ export async function compactInteractiveMessages(input: {
},
},
});
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,
}),
};
}
+193 -1
View File
@@ -2,7 +2,15 @@ import { createTool } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../../utils/types";
import { resolveSystemPrompt } from "../prompt";
import { applyInteractiveModeConfig } from "./mode";
import {
ACT_MODE_CONTINUATION_PROMPT,
type AppliedModeChange,
applyInteractiveModeConfig,
createInteractiveModeSwitchTool,
createModeSwitchNoticeTracker,
type PendingModeChange,
sendTurnWithActModeContinuation,
} from "./mode";
vi.mock("../prompt", () => ({
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
@@ -40,6 +48,190 @@ const switchToActModeTool = createTool({
execute: async () => "ok",
});
describe("createInteractiveModeSwitchTool", () => {
function makeSwitchTool(config: Config) {
const pendingModeChange: PendingModeChange = {
current: null,
source: null,
};
const tuiModeChanged: {
current: ((mode: "plan" | "act") => void) | null;
} = { current: vi.fn() };
const tool = createInteractiveModeSwitchTool({
config,
pendingModeChange,
tuiModeChanged,
});
return { tool, pendingModeChange, tuiModeChanged };
}
const toolContext = {
agentId: "agent-1",
iteration: 0,
} as const;
it("completes the run so the model never continues with plan-mode tools", () => {
const config = makeConfig();
config.mode = "plan";
const { tool } = makeSwitchTool(config);
// The act-mode tool set only exists after the session rebuild, which
// happens between runs; without completesRun the model keeps working
// with stale plan-mode tools after being told the switch succeeded.
expect(tool.lifecycle?.completesRun).toBe(true);
});
it("queues a tool-sourced mode change and notifies the TUI", async () => {
const config = makeConfig();
config.mode = "plan";
const { tool, pendingModeChange, tuiModeChanged } = makeSwitchTool(config);
const result = await tool.execute({}, toolContext);
expect(pendingModeChange).toEqual({ current: "act", source: "tool" });
expect(tuiModeChanged.current).toHaveBeenCalledWith("act");
expect(result).toContain("successfully switched to act mode");
});
it("errors instead of completing the run when already in act mode", async () => {
const config = makeConfig();
config.mode = "act";
const { tool, pendingModeChange } = makeSwitchTool(config);
// A successful result would end the run via completesRun even though
// nothing changed, so the no-op case must surface as a tool error.
await expect(tool.execute({}, toolContext)).rejects.toThrow(
"Already in act mode.",
);
expect(pendingModeChange.current).toBeNull();
});
});
describe("sendTurnWithActModeContinuation", () => {
type TurnResult = { finishReason: string; iterations: number };
function makeHarness(input: {
initial: TurnResult | undefined;
continuation?: TurnResult | undefined;
modeChanges: Array<AppliedModeChange | undefined>;
}) {
const applied = [...input.modeChanges];
const sendContinuationTurn = vi.fn(async () => input.continuation);
return {
sendContinuationTurn,
run: () =>
sendTurnWithActModeContinuation<TurnResult>({
sendInitialTurn: async () => input.initial,
sendContinuationTurn,
applyPendingModeChange: async () => applied.shift(),
}),
};
}
it("continues the plan after a tool-initiated switch completes the run", async () => {
const { run, sendContinuationTurn } = makeHarness({
initial: { finishReason: "completed", iterations: 2 },
continuation: { finishReason: "completed", iterations: 3 },
modeChanges: [{ mode: "act", source: "tool" }, undefined],
});
const result = await run();
expect(sendContinuationTurn).toHaveBeenCalledWith(
ACT_MODE_CONTINUATION_PROMPT,
);
expect(result).toEqual({ finishReason: "completed", iterations: 5 });
});
it("does not continue after a UI-initiated mode change", async () => {
// A Tab toggle can race a natural turn completion; a "ui" source must
// never start executing a plan the user did not approve.
const { run, sendContinuationTurn } = makeHarness({
initial: { finishReason: "completed", iterations: 2 },
modeChanges: [{ mode: "act", source: "ui" }],
});
const result = await run();
expect(sendContinuationTurn).not.toHaveBeenCalled();
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
});
it("does not continue when the switch turn was aborted", async () => {
const { run, sendContinuationTurn } = makeHarness({
initial: { finishReason: "aborted", iterations: 1 },
modeChanges: [{ mode: "act", source: "tool" }],
});
const result = await run();
expect(sendContinuationTurn).not.toHaveBeenCalled();
expect(result).toEqual({ finishReason: "aborted", iterations: 1 });
});
it("does not continue when no mode change was pending", async () => {
const { run, sendContinuationTurn } = makeHarness({
initial: { finishReason: "completed", iterations: 2 },
modeChanges: [undefined],
});
const result = await run();
expect(sendContinuationTurn).not.toHaveBeenCalled();
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
});
it("returns the switch turn result when the continuation yields nothing", async () => {
const { run } = makeHarness({
initial: { finishReason: "completed", iterations: 2 },
continuation: undefined,
modeChanges: [{ mode: "act", source: "tool" }, undefined],
});
const result = await run();
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
});
});
describe("createModeSwitchNoticeTracker", () => {
it("records a switch and clears it on consume", () => {
const tracker = createModeSwitchNoticeTracker();
tracker.record("act", "plan");
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
expect(tracker.consume()).toBeNull();
});
it("cancels a round trip that returns to the mode the model last saw", () => {
const tracker = createModeSwitchNoticeTracker();
tracker.record("act", "plan");
tracker.record("plan", "act");
expect(tracker.consume()).toBeNull();
});
it("keeps the original starting mode across chained switches", () => {
const tracker = createModeSwitchNoticeTracker();
tracker.record("act", "plan");
tracker.record("plan", "act");
tracker.record("act", "plan");
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
});
it("ignores a no-op switch", () => {
const tracker = createModeSwitchNoticeTracker();
tracker.record("plan", "plan");
expect(tracker.consume()).toBeNull();
});
});
describe("applyInteractiveModeConfig", () => {
beforeEach(() => {
vi.mocked(resolveSystemPrompt).mockClear();
+113 -4
View File
@@ -2,17 +2,42 @@ import { createTool } from "@cline/shared";
import type { Config } from "../../utils/types";
import { resolveSystemPrompt } from "../prompt";
type InteractiveUiMode = "plan" | "act";
export type InteractiveUiMode = "plan" | "act";
/**
* Pending mode change plus who requested it. The switch_to_act_mode tool and
* the TUI mode toggle share this slot, but only a tool-initiated switch means
* "the user approved the plan" -- a UI toggle that lands as a turn finishes
* must not trigger plan execution.
*/
export type PendingModeChange = {
current: InteractiveUiMode | null;
source: "tool" | "ui" | null;
};
export type AppliedModeChange = {
mode: InteractiveUiMode;
source: "tool" | "ui";
};
/**
* Canned prompt that drives the auto-continue turn after the model calls
* switch_to_act_mode. It is a synthetic user message, so transcript hydration
* filters it out of the chat display.
*/
export const ACT_MODE_CONTINUATION_PROMPT =
"The user approved switching to act mode. Continue with the approved plan now.";
export function createInteractiveModeSwitchTool(input: {
config: Config;
pendingModeChange: { current: InteractiveUiMode | null };
pendingModeChange: PendingModeChange;
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
}) {
return createTool({
name: "switch_to_act_mode",
description:
"Switch from plan mode to act mode. Call this after the user has confirmed they want to proceed with the plan. Do not call this proactively or before the user has agreed.",
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
inputSchema: {
type: "object",
properties: {},
@@ -20,17 +45,101 @@ export function createInteractiveModeSwitchTool(input: {
timeoutMs: 5000,
retryable: false,
maxRetries: 0,
// The act-mode tools only exist after the session is rebuilt with the
// new mode config, which can't happen mid-run. End the run right after
// the tool result so the model never keeps working with plan-mode tools
// it was just told it no longer has; run-interactive applies the pending
// change and auto-continues on the rebuilt session.
lifecycle: {
completesRun: true,
},
execute: async () => {
if (input.config.mode === "act") {
return "Already in act mode.";
// Throw instead of returning: a successful result would end the
// run via completesRun even though nothing changed.
throw new Error("Already in act mode.");
}
input.pendingModeChange.current = "act";
input.pendingModeChange.source = "tool";
input.tuiModeChanged.current?.("act");
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
},
});
}
/**
* Runs one interactive turn, and when the model ended it by calling
* switch_to_act_mode, continues the approved plan on the rebuilt act-mode
* session instead of waiting for the user to prompt again.
*
* The continuation only fires for a tool-initiated switch on a turn that
* finished "completed": a UI toggle mid-run aborts the turn, and even if the
* toggle races a natural completion its source is "ui", so the user's Tab
* press can never start executing a plan they did not approve.
*/
export async function sendTurnWithActModeContinuation<
T extends { finishReason: string; iterations: number },
>(input: {
sendInitialTurn: () => Promise<T | undefined>;
sendContinuationTurn: (prompt: string) => Promise<T | undefined>;
applyPendingModeChange: () => Promise<AppliedModeChange | undefined>;
}): Promise<T | undefined> {
const result = await input.sendInitialTurn();
const switched = await input.applyPendingModeChange();
if (
switched?.mode !== "act" ||
switched.source !== "tool" ||
result?.finishReason !== "completed"
) {
return result;
}
const continuation = await input.sendContinuationTurn(
ACT_MODE_CONTINUATION_PROMPT,
);
// Honor a mode toggle made while the continuation was running.
await input.applyPendingModeChange();
if (!continuation) {
return result;
}
return {
...continuation,
iterations: result.iterations + continuation.iterations,
};
}
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;
},
};
}
export async function applyInteractiveModeConfig(input: {
config: Config;
mode: InteractiveUiMode;
@@ -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,6 +151,8 @@ 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(),
@@ -133,7 +172,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 +189,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 +213,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 +542,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();
@@ -210,18 +550,54 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
expect(runtime.getActiveSessionId()).toBe("session-1");
// Keep the replacement session's start in flight so the restart window
// (old session stopped, no active session yet) stays open.
const gate = deferred<void>();
manager.start.mockImplementationOnce(async () => {
await gate.promise;
return {
sessionId: "session-restarted",
manifest: createManifest("session-restarted"),
manifestPath: "/tmp/session-restarted.json",
messagesPath: "/tmp/session-restarted.messages.json",
};
});
const restart = runtime.restartWithCurrentMessages();
await vi.waitFor(() => {
expect(manager.start).toHaveBeenCalledTimes(2);
});
// A message submitted mid-restart (e.g. right after a plan/act toggle)
// calls ensureReady; it must wait for the restart instead of booting a
// blank session that races the replacement for the active slot.
const ready = runtime.ensureReady();
gate.resolve();
await Promise.all([restart, ready]);
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-restarted");
});
it("adds a live interactive approval policy hook to started sessions", async () => {
const manager = makeManager();
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",
}),
@@ -273,14 +649,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",
@@ -288,16 +701,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,
@@ -314,8 +725,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();
@@ -337,7 +786,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({
@@ -370,7 +819,7 @@ describe("createInteractiveSessionRuntime", () => {
manager.readMessages.mockRejectedValueOnce(
new SessionNotFoundError("session-1"),
);
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
@@ -388,7 +837,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 [
@@ -398,7 +847,7 @@ describe("createInteractiveSessionRuntime", () => {
},
];
});
runtime = makeRuntime(manager);
runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
@@ -416,7 +865,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,
@@ -116,6 +119,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,6 +209,7 @@ export function createInteractiveSessionRuntime(input: {
const startFreshSession = async (
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
@@ -214,6 +219,7 @@ export function createInteractiveSessionRuntime(input: {
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
...(initialCompactionState ? { initialCompactionState } : {}),
...(sessionMetadata ? { sessionMetadata } : {}),
localRuntime: {
onTeamRestored: () => {},
@@ -309,6 +315,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 +368,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) {
@@ -377,28 +411,69 @@ export function createInteractiveSessionRuntime(input: {
});
};
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupPromise = undefined;
startupError = undefined;
await stopCurrentSession();
clearActiveSession();
await startFreshSession(messages, sessionMetadata);
};
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
// Publish the restart as the in-flight startup. Teardown leaves a window
// with no active session, and without this barrier a concurrent
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
// reads that window as "no session" and boots an empty session that then
// races the restarted one for the active slot.
const restart = (async () => {
await stopCurrentSession();
clearActiveSession();
await startFreshSession(
messages,
sessionMetadata,
initialCompactionState,
);
})().catch((error) => {
startupError = error;
throw error;
});
startupPromise = restart;
try {
await restart;
} finally {
// Restore the pre-restart steady state (startupPromise unset) so a
// failed restart stays retryable by the next ensureReady(). A newer
// startup that already replaced the barrier is left alone.
if (startupPromise === restart) {
startupPromise = undefined;
}
}
};
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,
);
};
const restartEmpty = async (): Promise<void> => {
@@ -512,6 +587,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,
@@ -519,7 +598,17 @@ export function createInteractiveSessionRuntime(input: {
sourceSession: sessionRecord,
messages,
});
await startFreshSession(messages, forkMetadata);
await startFreshSession(
messages,
forkMetadata,
projectedMessages
? createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
})
: undefined,
);
return { forkedFromSessionId, newSessionId: activeSessionId };
};
@@ -541,9 +630,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();
@@ -557,12 +654,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,
@@ -570,10 +683,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,
};
};
@@ -663,6 +790,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(() => {});
+11 -4
View File
@@ -9,6 +9,10 @@ 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.
@@ -20,7 +24,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
When the user aligns on a plan and is ready to proceed, use the switch_to_act_mode tool to switch to act mode and begin implementation.`;
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;
@@ -31,10 +35,13 @@ export async function resolveSystemPrompt(input: {
}): 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
? `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`
: PLAN_MODE_INSTRUCTIONS;
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
}
return buildClineSystemPrompt({
ide: "Terminal Shell",
+53 -16
View File
@@ -4,6 +4,7 @@ import {
ProviderSettingsManager,
type UserInstructionConfigService,
} from "@cline/core";
import { formatModeSwitchNotice } from "@cline/shared";
import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
@@ -52,7 +53,13 @@ import {
type InteractiveExitSummary,
} from "./interactive/exit-summary";
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
import { createInteractiveModeSwitchTool } from "./interactive/mode";
import {
type AppliedModeChange,
createInteractiveModeSwitchTool,
createModeSwitchNoticeTracker,
type PendingModeChange,
sendTurnWithActModeContinuation,
} from "./interactive/mode";
import { assertInteractivePreflight } from "./interactive/preflight";
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
import { buildUserInputMessage } from "./prompt";
@@ -149,8 +156,9 @@ export async function runInteractive(
tuiAskQuestion,
} = createInteractiveApprovalController(config);
const pendingModeChange: { current: "plan" | "act" | null } = {
const pendingModeChange: PendingModeChange = {
current: null,
source: null,
};
const tuiModeChanged: {
current: ((mode: "plan" | "act") => void) | null;
@@ -204,6 +212,7 @@ export async function runInteractive(
});
let modeChangePromise: Promise<void> | undefined;
let modeChangeTarget: "plan" | "act" | undefined;
const modeSwitchNotice = createModeSwitchNoticeTracker();
const isInteractiveMode = (mode: unknown): mode is "plan" | "act" =>
mode === "plan" || mode === "act";
@@ -218,7 +227,11 @@ export async function runInteractive(
await modeChangePromise;
}
await sessionRuntime.ensureReady();
const from = config.mode;
await sessionRuntime.applyMode(mode);
if (isInteractiveMode(from)) {
modeSwitchNotice.record(from, mode);
}
})().finally(() => {
if (modeChangePromise === next) {
modeChangePromise = undefined;
@@ -520,27 +533,50 @@ export async function runInteractive(
...(attachments?.userImages ?? []),
...userImages,
];
// Mark a preceding user-initiated mode switch on this message so
// the model sees exactly when the rules changed, instead of only
// inferring it from the user_input mode attribute flipping.
const switchNotice = modeSwitchNotice.consume();
const noticedUserInput = switchNotice
? `${formatModeSwitchNotice(switchNotice.from, switchNotice.to)}\n${userInput}`
: userInput;
const applyPendingModeChange = async () => {
const applyPendingModeChange = async (): Promise<
AppliedModeChange | undefined
> => {
if (!pendingModeChange.current) return undefined;
const newMode = pendingModeChange.current;
const applied: AppliedModeChange = {
mode: pendingModeChange.current,
source: pendingModeChange.source ?? "ui",
};
pendingModeChange.current = null;
await sessionRuntime.applyMode(newMode);
tuiModeChanged.current?.(newMode);
return newMode;
pendingModeChange.source = null;
const from = config.mode;
await sessionRuntime.applyMode(applied.mode);
tuiModeChanged.current?.(applied.mode);
// The switch_to_act_mode path announces itself through the
// continuation prompt; only UI toggles need a notice.
if (applied.source === "ui" && isInteractiveMode(from)) {
modeSwitchNotice.record(from, applied.mode);
}
return applied;
};
const result = await sessionRuntime.sendCurrentTurn({
prompt: userInput,
mode,
userImages:
mergedUserImages.length > 0 ? mergedUserImages : undefined,
userFiles: userFiles.length > 0 ? userFiles : undefined,
delivery,
const result = await sendTurnWithActModeContinuation({
sendInitialTurn: () =>
sessionRuntime.sendCurrentTurn({
prompt: noticedUserInput,
mode,
userImages:
mergedUserImages.length > 0 ? mergedUserImages : undefined,
userFiles: userFiles.length > 0 ? userFiles : undefined,
delivery,
}),
sendContinuationTurn: (prompt) =>
sessionRuntime.sendCurrentTurn({ prompt, mode: "act" }),
applyPendingModeChange,
});
await applyPendingModeChange();
if (!result) {
return {
usage: { inputTokens: 0, outputTokens: 0 },
@@ -642,6 +678,7 @@ export async function runInteractive(
if (!isInteractiveMode(mode)) return;
if (isRunning) {
pendingModeChange.current = mode;
pendingModeChange.source = "ui";
sessionRuntime.abortAll();
return;
}
+7 -6
View File
@@ -1,10 +1,11 @@
import {
type ContentBlock,
formatDisplayUserInput,
type MessageWithMetadata,
normalizeUserInput,
type ToolResultContent,
type ToolUseContent,
} from "@cline/shared";
import { formatStructuredCommand } from "../utils/helpers";
export interface ConversationHistory {
version: number;
@@ -680,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);
}
@@ -688,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":
@@ -845,15 +846,15 @@ function renderDiffHTML(
}
function renderCommandsHTML(
commands: string[],
commands: unknown[],
_result?: ToolResultContent,
): string {
return commands
.map(
(cmd, i) => `
(command, i) => `
<div class="command-block">
<div class="command-label">Command ${i + 1}</div>
<code>${escapeHtml(cmd)}</code>
<code>${escapeHtml(formatStructuredCommand(command))}</code>
</div>
`,
)
+2 -1
View File
@@ -128,7 +128,8 @@ export async function createClineAccountService(input: {
clineProviderSettings?: ProviderSettings;
providerSettingsManager?: ProviderSettingsManager;
}): Promise<ClineAccountService | undefined> {
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
const manager =
input.providerSettingsManager ?? new ProviderSettingsManager();
const settings =
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
const apiBaseUrl = resolveAccountApiBaseUrl({
+14 -17
View File
@@ -18,12 +18,12 @@ 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 { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
parseApplyPatchInput,
@@ -291,7 +291,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 +299,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 +377,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>
@@ -422,16 +422,15 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
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 +441,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 +459,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 +486,7 @@ export function ChatEntryView(props: {
<box flexGrow={1}>
<markdown
content={content}
syntaxStyle={getSyntaxStyle(terminalTheme)}
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
streaming={entry.streaming}
fg={defaultFg}
/>
@@ -565,7 +562,7 @@ export function ChatEntryView(props: {
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,3 +1,5 @@
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";
@@ -9,5 +11,6 @@ export function buildClinePassSubscriptionPageUrl(
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
url.searchParams.set("code", CLI_PROMO_CODE);
return url.toString();
}
@@ -4,7 +4,7 @@ import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
);
});
@@ -12,7 +12,7 @@ describe("buildClinePassSubscriptionPageUrl", () => {
expect(
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
).toBe(
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
);
});
});
@@ -371,14 +371,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 +596,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 +689,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 +707,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>
@@ -869,7 +869,7 @@ export function OAuthLoginContent(
if (mode === "device") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
@@ -884,7 +884,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>
@@ -901,7 +901,7 @@ export function OAuthLoginContent(
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
@@ -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
@@ -27,7 +27,7 @@ export type ClineModelPickerEntry =
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return "cyan";
return palette.act;
}
function resolveDisplayName(
@@ -17,7 +17,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(
@@ -272,7 +272,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 +282,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;
+8 -1
View File
@@ -1,4 +1,5 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { formatDisplayUserInput } from "@cline/shared";
import { useCallback, useRef } from "react";
import type {
PendingPromptSnapshot,
@@ -296,7 +297,13 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
const handlePendingPromptSubmitted = useCallback(
(event: PendingPromptSubmittedEvent) => {
knownPendingPromptIdsRef.current.delete(event.id);
appendEntry({ kind: "user_submitted", text: event.prompt });
// Display boundary: formatDisplayUserInput strips runtime-generated
// notice elements (e.g. mode_notice) that normalizeUserInput must
// preserve, since the latter also sanitizes model-bound prompts.
appendEntry({
kind: "user_submitted",
text: formatDisplayUserInput(event.prompt),
});
},
[appendEntry],
);
@@ -161,7 +161,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", () => {
@@ -75,9 +75,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);
}
+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),
);
}
+4 -3
View File
@@ -401,9 +401,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);
+13 -2
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 }
@@ -52,7 +52,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 +90,7 @@ export interface ResumedSessionResult {
export interface InteractiveCompactionResult {
messagesBefore: number;
messagesAfter: number;
workingContextMessagesAfter?: number;
compacted: boolean;
}
+10 -3
View File
@@ -1,5 +1,9 @@
import type { InteractiveCompactionResult } from "../types";
function formatMessageCount(count: number): string {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
export function formatCompactionStatus(
result: InteractiveCompactionResult,
): string {
@@ -9,8 +13,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)}.`;
}
@@ -0,0 +1,154 @@
import type { Message } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
import { hydrateSessionMessages } from "./hydrate-messages";
describe("hydrateSessionMessages", () => {
it("renders regular user messages", () => {
const messages = [
{
role: "user",
content: '<user_input mode="plan">lets do it</user_input>',
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ kind: "user_submitted", text: "lets do it", mode: "plan" },
]);
});
it("hides the synthetic act-mode continuation prompt", () => {
const messages = [
{
role: "user",
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
},
{
role: "user",
content: [
{
type: "text",
text: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
},
],
},
{
role: "assistant",
content: "On it.",
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{ 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,
},
]);
});
});
+35 -4
View File
@@ -1,4 +1,10 @@
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";
@@ -11,6 +17,12 @@ function getDisplayRole(msg: PersistedMessage): string | undefined {
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
}
// The act-mode continuation prompt is runtime-generated, not typed by the
// user, so it should not surface as a user bubble in the transcript.
function isSyntheticUserText(text: string): boolean {
return text === ACT_MODE_CONTINUATION_PROMPT;
}
function stringifyToolResult(
content: string | Array<{ type: string; text?: string; path?: string }>,
): string {
@@ -30,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);
@@ -39,13 +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) entries.push({ kind: "user_submitted", text });
if (text && !isSyntheticUserText(text)) {
entries.push({ kind: "user_submitted", text, mode });
}
} else {
entries.push({
kind: "assistant_text",
text: msg.content,
streaming: false,
mode,
});
}
continue;
@@ -62,6 +84,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
kind: "assistant_text",
text: block.text,
streaming: false,
mode,
});
}
continue;
@@ -72,6 +95,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
kind: "reasoning",
text: block.thinking,
streaming: false,
mode,
});
continue;
}
@@ -87,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;
}
@@ -114,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) {
entries.push({ kind: "user_submitted", text });
if (text && !isSyntheticUserText(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}
@@ -58,6 +58,7 @@ import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
DEFAULT_THINKING_LEVEL_INDEX,
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
@@ -237,7 +238,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
}, []);
// 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 +644,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 +694,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");
@@ -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;
@@ -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>
+3 -1
View File
@@ -11,9 +11,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export const CLI_PROMO_CODE = "CLI-8OFF";
export function getCliSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-8OFF&personal=true",
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
+1 -1
View File
@@ -61,7 +61,7 @@ export function truncate(str: string, maxLen: number): string {
return `${oneLine.slice(0, maxLen - 3)}...`;
}
function formatStructuredCommand(cmd: unknown): string {
export function formatStructuredCommand(cmd: unknown): string {
if (typeof cmd === "string") {
return cmd;
}
+15 -6
View File
@@ -77,12 +77,17 @@ function getOwnServerRecord(
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
* for normal no-op cases instead of returning a boolean that callers can ignore.
*/
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
function mutateServers(
mutate: (servers: Record<string, unknown>) => void,
): void {
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
const serversValue = settings.mcpServers;
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
? { ...(serversValue as Record<string, unknown>) }
: {};
const servers =
serversValue &&
typeof serversValue === "object" &&
!Array.isArray(serversValue)
? { ...(serversValue as Record<string, unknown>) }
: {};
mutate(servers);
settings.mcpServers = servers;
});
@@ -98,7 +103,9 @@ export function removeServer(name: string): boolean {
try {
mutateServers((servers) => {
if (!(name in servers)) {
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
throw new McpSettingsUpdateSkippedError(
`MCP server not found: ${name}`,
);
}
delete servers[name];
});
@@ -126,7 +133,9 @@ export function clearServerOAuth(name: string): void {
mutateServers((servers) => {
const existing = getOwnServerRecord(servers, name);
if (!existing) {
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
throw new McpSettingsUpdateSkippedError(
`MCP server not found: ${name}`,
);
}
delete existing.oauth;
servers[name] = existing;
+6 -3
View File
@@ -85,7 +85,8 @@ export function setMcpServerDisabled(
// Hold the cross-process lock across read-modify-write so a concurrent writer
// (the extension, the CLI) cannot clobber this change.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
@@ -128,7 +129,8 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot clobber this upsert.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
@@ -143,7 +145,8 @@ export function deleteMcpServer(name: string): JsonRecord {
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot resurrect the deleted server from a stale snapshot.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
delete servers[name];
settings.mcpServers = servers;
});
@@ -0,0 +1,231 @@
import { describe, expect, it } from "vitest";
import { mapHistoryToWebviewMessages } from "./session-mapping";
describe("mapHistoryToWebviewMessages", () => {
it("hydrates assistant tool uses with following user tool results", () => {
const messages = mapHistoryToWebviewMessages([
{
id: "assistant-1",
role: "assistant",
content: [
{ type: "text", text: "I'll inspect the file." },
{
type: "tool_use",
id: "toolu_1",
name: "read_file",
input: { path: "src/index.ts" },
},
],
},
{
id: "user-1",
role: "user",
content: [
{
type: "tool_result",
id: "result-block-1",
tool_use_id: "toolu_1",
name: "read_file",
content: "export const value = 1;",
},
],
},
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "assistant-1",
role: "assistant",
text: "I'll inspect the file.",
toolEvents: [
{
toolCallId: "toolu_1",
name: "read_file",
state: "output-available",
input: { path: "src/index.ts" },
output: "export const value = 1;",
},
],
});
expect(messages[0].blocks).toEqual([
{
id: "assistant-1:text:0",
type: "text",
text: "I'll inspect the file.",
},
{
id: "assistant-1:tool:toolu_1",
type: "tool",
toolEvent: expect.objectContaining({
toolCallId: "toolu_1",
name: "read_file",
state: "output-available",
output: "export const value = 1;",
}),
},
]);
});
it("hydrates error tool results", () => {
const messages = mapHistoryToWebviewMessages([
{
id: "assistant-1",
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_1",
name: "read_file",
input: { path: "missing.ts" },
},
],
},
{
id: "user-1",
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_1",
name: "read_file",
content: "File not found",
is_error: true,
},
],
},
]);
expect(messages).toHaveLength(1);
expect(messages[0].toolEvents).toEqual([
expect.objectContaining({
toolCallId: "toolu_1",
name: "read_file",
state: "output-error",
output: "File not found",
error: "File not found",
}),
]);
expect(messages[0].blocks?.[0]).toMatchObject({
type: "tool",
toolEvent: {
toolCallId: "toolu_1",
state: "output-error",
error: "File not found",
},
});
});
it("hydrates orphan tool results as standalone meta tool blocks", () => {
const messages = mapHistoryToWebviewMessages([
{
id: "user-1",
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_orphan",
name: "read_file",
content: "orphan output",
},
],
},
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "user-1",
role: "meta",
text: "",
toolEvents: [
{
toolCallId: "toolu_orphan",
name: "read_file",
state: "output-available",
input: undefined,
output: "orphan output",
},
],
});
expect(messages[0].blocks).toEqual([
{
id: "user-1:tool:toolu_orphan",
type: "tool",
toolEvent: expect.objectContaining({
toolCallId: "toolu_orphan",
input: undefined,
output: "orphan output",
}),
},
]);
});
it("hydrates plain string content as a text block", () => {
const messages = mapHistoryToWebviewMessages([
{
id: "assistant-1",
role: "assistant",
content: "Plain response",
},
]);
expect(messages).toEqual([
{
id: "assistant-1",
role: "assistant",
text: "Plain response",
reasoning: undefined,
reasoningRedacted: undefined,
toolEvents: undefined,
blocks: [
{
id: "assistant-1:text:0",
type: "text",
text: "Plain response",
},
],
},
]);
});
it("hydrates same-message tool-call and tool-result blocks", () => {
const messages = mapHistoryToWebviewMessages([
{
id: "assistant-1",
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_1",
toolName: "search",
input: { query: "cline" },
},
{
type: "tool-result",
toolCallId: "call_1",
toolName: "search",
output: [{ query: "cline", result: "found", success: true }],
},
],
},
]);
expect(messages).toHaveLength(1);
expect(messages[0].toolEvents).toEqual([
expect.objectContaining({
toolCallId: "call_1",
name: "search",
state: "output-available",
input: { query: "cline" },
output: [{ query: "cline", result: "found", success: true }],
}),
]);
expect(messages[0].blocks).toHaveLength(1);
expect(messages[0].blocks?.[0]).toMatchObject({
type: "tool",
toolEvent: {
toolCallId: "call_1",
state: "output-available",
},
});
});
});
+280 -8
View File
@@ -1,3 +1,4 @@
import { formatDisplayUserInput } from "@cline/shared";
import type {
WebviewActionSessionSummary,
WebviewChatMessage,
@@ -9,6 +10,7 @@ import type { HubContext } from "./state";
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
import {
asNumber,
asRecord,
asString,
asTimestamp,
basename,
@@ -88,27 +90,297 @@ function summarizeClient(client: TrackedClient): {
};
}
type HistoryToolLocation = {
messageIndex: number;
blockIndex: number;
};
function historyContentParts(content: unknown): Record<string, unknown>[] {
if (Array.isArray(content)) {
return content
.map((part) => asRecord(part))
.filter((part): part is Record<string, unknown> => Boolean(part));
}
if (typeof content === "string" && content.trim()) {
return [{ type: "text", text: content }];
}
return [];
}
function blockType(block: Record<string, unknown>): string {
return asString(block.type)?.toLowerCase() ?? "";
}
function toolCallIdForCall(block: Record<string, unknown>): string | undefined {
return (
asString(block.id) ??
asString(block.toolCallId) ??
asString(block.tool_call_id)
);
}
function toolCallIdForResult(
block: Record<string, unknown>,
): string | undefined {
return (
asString(block.tool_use_id) ??
asString(block.toolCallId) ??
asString(block.tool_call_id)
);
}
function toolNameFor(block: Record<string, unknown>): string {
return (
asString(block.name) ??
asString(block.toolName) ??
asString(block.tool_name) ??
"tool"
);
}
function toolInputFor(block: Record<string, unknown>): unknown {
return block.input ?? block.args ?? block.arguments;
}
function toolOutputFor(block: Record<string, unknown>): unknown {
return block.output ?? block.result ?? block.content;
}
function isErrorToolResult(block: Record<string, unknown>): boolean {
return (
block.is_error === true || block.isError === true || block.error === true
);
}
function pushTextBlock(
blocks: NonNullable<WebviewChatMessage["blocks"]>,
textParts: string[],
messageKey: string | number,
partIndex: number,
text: string,
): void {
if (!text) return;
textParts.push(text);
blocks.push({
id: `${messageKey}:text:${partIndex}`,
type: "text",
text,
});
}
function pushReasoningBlock(
blocks: NonNullable<WebviewChatMessage["blocks"]>,
reasoningParts: string[],
messageKey: string | number,
partIndex: number,
text: string,
redacted?: boolean,
): boolean {
if (!text) return false;
reasoningParts.push(text);
blocks.push({
id: `${messageKey}:reasoning:${partIndex}`,
type: "reasoning",
text,
redacted,
});
return redacted === true;
}
export function mapHistoryToWebviewMessages(
history: unknown[],
): WebviewChatMessage[] {
return history.map((entry, index) => {
const mapped: WebviewChatMessage[] = [];
const toolLocations = new Map<string, HistoryToolLocation>();
for (const [index, entry] of history.entries()) {
const record =
entry && typeof entry === "object"
? (entry as Record<string, unknown>)
: { content: entry };
const messageKey = asString(record.id) ?? `history-${index}`;
const rawRole = asString(record.role)?.toLowerCase();
const role: WebviewChatMessage["role"] =
let role: WebviewChatMessage["role"] =
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
? rawRole
: "meta";
const text = stringifyContent(record.content ?? record.text ?? record);
return {
id: asString(record.id) ?? `history-${index}`,
const blocks: NonNullable<WebviewChatMessage["blocks"]> = [];
const textParts: string[] = [];
const reasoningParts: string[] = [];
const toolEvents = new Map<
string,
NonNullable<WebviewChatMessage["toolEvents"]>[number]
>();
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, displayText(text));
}
for (const [partIndex, part] of contentParts.entries()) {
const type = blockType(part);
if (type === "text") {
pushTextBlock(
blocks,
textParts,
messageKey,
partIndex,
displayText(asString(part.text) ?? asString(part.content) ?? ""),
);
continue;
}
if (type === "thinking" || type === "reasoning") {
reasoningRedacted =
pushReasoningBlock(
blocks,
reasoningParts,
messageKey,
partIndex,
asString(part.thinking) ??
asString(part.reasoning) ??
asString(part.text) ??
"",
part.redacted === true,
) || reasoningRedacted;
continue;
}
if (type === "redacted_thinking") {
reasoningRedacted =
pushReasoningBlock(
blocks,
reasoningParts,
messageKey,
partIndex,
"[redacted]",
true,
) || reasoningRedacted;
continue;
}
if (type === "tool_use" || type === "tool-call") {
const toolCallId =
toolCallIdForCall(part) ?? `${messageKey}:${partIndex}`;
const name = toolNameFor(part);
const toolEvent = {
id: `${messageKey}:${toolCallId}`,
toolCallId,
name,
text: `Running ${name}...`,
state: "input-available" as const,
input: toolInputFor(part),
};
toolEvents.set(toolCallId, toolEvent);
blocks.push({
id: `${messageKey}:tool:${toolCallId}`,
type: "tool",
toolEvent,
});
currentToolBlockIndexes.set(toolCallId, blocks.length - 1);
toolLocations.set(toolCallId, {
messageIndex: mapped.length,
blockIndex: blocks.length - 1,
});
continue;
}
if (type === "tool_result" || type === "tool-result") {
const toolCallId =
toolCallIdForResult(part) ?? `${messageKey}:${partIndex}`;
const name = toolNameFor(part);
const output = toolOutputFor(part);
const isError = isErrorToolResult(part);
const currentBlockIndex = currentToolBlockIndexes.get(toolCallId);
const existingLocation = toolLocations.get(toolCallId);
const existing =
currentBlockIndex !== undefined
? blocks[currentBlockIndex]
: existingLocation !== undefined
? mapped[existingLocation.messageIndex]?.blocks?.[
existingLocation.blockIndex
]
: undefined;
const existingToolEvent =
existing?.type === "tool" ? existing.toolEvent : undefined;
const toolEvent = {
id: existingToolEvent?.id ?? `${messageKey}:${toolCallId}`,
toolCallId,
name: existingToolEvent?.name ?? name,
text: isError
? `${existingToolEvent?.name ?? name} failed`
: `${existingToolEvent?.name ?? name} completed`,
state: isError
? ("output-error" as const)
: ("output-available" as const),
input: existingToolEvent?.input,
output,
error: isError ? stringifyContent(output) : undefined,
};
if (currentBlockIndex !== undefined && existing?.type === "tool") {
blocks[currentBlockIndex] = {
...existing,
toolEvent,
};
toolEvents.set(toolCallId, toolEvent);
} else if (
existingLocation !== undefined &&
existing?.type === "tool"
) {
const target = mapped[existingLocation.messageIndex];
const targetBlocks = target.blocks;
const targetBlock = targetBlocks?.[existingLocation.blockIndex];
if (targetBlocks && targetBlock?.type === "tool") {
targetBlocks[existingLocation.blockIndex] = {
...targetBlock,
toolEvent,
};
}
target.toolEvents = (target.toolEvents ?? []).map((event) =>
event.toolCallId === toolCallId ? toolEvent : event,
);
} else {
toolEvents.set(toolCallId, toolEvent);
blocks.push({
id: `${messageKey}:tool:${toolCallId}`,
type: "tool",
toolEvent,
});
}
}
}
const text = textParts.join("\n");
const toolEventList = [...toolEvents.values()];
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
continue;
}
if (!text && role === "user" && toolEventList.length > 0) {
role = "meta";
}
mapped.push({
id: messageKey,
role,
text,
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
};
});
reasoning:
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
reasoningRedacted: reasoningRedacted || undefined,
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
blocks,
});
}
return mapped;
}
export function trackSession(record: unknown): TrackedSession | undefined {
@@ -21,7 +21,7 @@ export function PageFrame({
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
<div className={cn("max-w-344", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
@@ -134,7 +134,7 @@ export function ProviderListContent({
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
Models
Model Providers
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
Configure model providers and choose which ones are available.{" "}
@@ -1,10 +1,5 @@
import { execFileSync, spawn } from "node:child_process";
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type {
ClineAccountActionRequest,
@@ -1043,7 +1038,8 @@ export async function handleCommand(
if (command === "set_mcp_server_disabled") {
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
const name = String(args?.name ?? "").trim();
const current = servers[name];
if (!current || typeof current !== "object") {
@@ -1094,7 +1090,8 @@ export async function handleCommand(
};
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
@@ -1106,7 +1103,8 @@ export async function handleCommand(
if (command === "delete_mcp_server") {
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
delete servers[String(args?.name ?? "")];
settings.mcpServers = servers;
});
+5
View File
@@ -284,6 +284,10 @@ message Settings {
optional string act_mode_cline_model_id = 180;
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
optional bool show_feature_tips = 182;
optional string plan_mode_cline_pass_model_id = 183;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
optional string act_mode_cline_pass_model_id = 185;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
}
message State {
@@ -425,6 +429,7 @@ message UpdateSettingsRequest {
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
optional bool show_feature_tips = 42;
optional string compaction_strategy = 44;
}
message UpdateTerminalConnectionTimeoutRequest {
+13
View File
@@ -237,6 +237,16 @@ message ShowWebviewEvent {
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
}
message IntentEvent {
string action = 1;
string source = 2;
bool has_text = 3;
bool has_images = 4;
bool has_files = 5;
bool has_active_task = 6;
int32 text_length = 7;
}
// UiService provides methods for managing UI interactions
service UiService {
// Scrolls to a specific settings section in the settings view
@@ -292,4 +302,7 @@ service UiService {
// Opens the Cline walkthrough
rpc openWalkthrough(EmptyRequest) returns (Empty);
// Tracks intent signals before task creation or first model activity
rpc trackIntent(IntentEvent) returns (Empty);
}
+54 -6
View File
@@ -14,18 +14,66 @@ import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
const require = createRequire(import.meta.url)
const isWindows = process.platform === "win32"
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
// Resolve the grpc-tools package root via its package.json (stable regardless of `main`), so we
// can both locate the bundled protoc and re-run its install script when the binary is missing.
const GRPC_TOOLS_DIR = path.dirname(require.resolve("grpc-tools/package.json"))
const GRPC_TOOLS_PROTOC = path.join(GRPC_TOOLS_DIR, "bin", isWindows ? "protoc.exe" : "protoc")
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
// `bun install` skips grpc-tools' `install` lifecycle script (`node-pre-gyp install`), so the prebuilt
// protoc is never downloaded into bin/. When it's missing, run that same command here to fetch it.
// grpc-tools depends on @mapbox/node-pre-gyp, which exposes the `node-pre-gyp` CLI.
function resolveNodePreGypCli() {
const candidates = ["@mapbox/node-pre-gyp/bin/node-pre-gyp", "node-pre-gyp/bin/node-pre-gyp"]
for (const candidate of candidates) {
try {
// Resolve from the grpc-tools package (its direct dependency).
return require.resolve(candidate, { paths: [GRPC_TOOLS_DIR] })
} catch {
// Fall back to resolving from this script's location (covers hoisted installs).
try {
return require.resolve(candidate)
} catch {
// try the next candidate
}
}
}
return null
}
function ensureProtocBinary() {
console.warn(chalk.yellow(`protoc not found at ${GRPC_TOOLS_PROTOC}; downloading the grpc-tools prebuilt binary...`))
const nodePreGypCli = resolveNodePreGypCli()
if (!nodePreGypCli) {
console.error(
chalk.red(
`Could not resolve the node-pre-gyp CLI from ${GRPC_TOOLS_DIR}. Run \`bun install\`, then retry \`bun run protos\`.`,
),
)
process.exit(1)
}
try {
// Mirrors grpc-tools' `scripts.install` ("node-pre-gyp install"): downloads the prebuilt
// protoc for the current platform/arch into grpc-tools/bin.
execFileSync(process.execPath, [nodePreGypCli, "install"], { cwd: GRPC_TOOLS_DIR, stdio: "inherit" })
} catch (error) {
console.error(chalk.red(`Failed to download protoc via node-pre-gyp: ${error?.message ?? error}`))
process.exit(1)
}
if (!fsSync.existsSync(GRPC_TOOLS_PROTOC)) {
console.error(chalk.red(`protoc still not found at ${GRPC_TOOLS_PROTOC} after node-pre-gyp install.`))
process.exit(1)
}
console.log(chalk.green("✓ protoc binary installed."))
}
if (!fsSync.existsSync(PROTOC)) {
const windowsHint = isWindows
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
: ""
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
process.exit(1)
// PROTOC only differs from GRPC_TOOLS_PROTOC when the legacy Windows path exists, so a missing
// PROTOC always means the grpc-tools-bundled protoc needs to be fetched.
ensureProtocBinary()
}
const PROTO_DIR = path.resolve("proto")
+1 -1
View File
@@ -85,7 +85,7 @@ function inferProtoType(typeText, fieldName) {
["FocusChainSettings", "FocusChainSettings"],
["OpenaiReasoningEffort", "OpenaiReasoningEffort"],
["PlanActMode", "PlanActMode"],
["ApiProvider", "ApiProvider"],
["ApiProvider", "string"],
["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check
]
@@ -4,6 +4,7 @@
// This allows the SdkController to reuse the classic state-building logic
// without inheriting the entire classic Controller implementation.
import { readCompactionStrategyGlobally } from "@cline/core"
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { ClineEnv } from "@/config"
@@ -40,6 +41,7 @@ export async function getStateToPostToWebview(controller: {
const mode = stateManager.getGlobalSettingsKey("mode")
const yoloModeToggled = stateManager.getGlobalSettingsKey("yoloModeToggled")
const useAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense")
const compactionStrategy = readCompactionStrategyGlobally()
const subagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled")
const userInfo = stateManager.getGlobalStateKey("userInfo")
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
@@ -118,6 +120,7 @@ export async function getStateToPostToWebview(controller: {
mode,
yoloModeToggled,
useAutoCondense,
compactionStrategy,
subagentsEnabled,
userInfo,
mcpMarketplaceEnabled,
@@ -58,9 +58,9 @@ describe("updateAutoApprovalSettings", () => {
},
}
expect(controller.stateManager.setGlobalState).toHaveBeenCalledWith("autoApprovalSettings", expectedSettings)
expect(controller.stateManager.setTaskSettings).toHaveBeenCalledWith("task-1", "autoApprovalSettings", expectedSettings)
expect(controller.postStateToWebview).toHaveBeenCalledOnce()
expect(controller.stateManager.setGlobalState.mock.calls).toEqual([["autoApprovalSettings", expectedSettings]])
expect(controller.stateManager.setTaskSettings.mock.calls).toEqual([["task-1", "autoApprovalSettings", expectedSettings]])
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
})
it("does not create a task override when no task is active", async () => {
@@ -76,9 +76,9 @@ describe("updateAutoApprovalSettings", () => {
}),
)
expect(controller.stateManager.setGlobalState).toHaveBeenCalledOnce()
expect(controller.stateManager.setTaskSettings).not.toHaveBeenCalled()
expect(controller.postStateToWebview).toHaveBeenCalledOnce()
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(1)
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
})
it("ignores stale auto-approval settings versions", async () => {
@@ -100,8 +100,8 @@ describe("updateAutoApprovalSettings", () => {
}),
)
expect(controller.stateManager.setGlobalState).not.toHaveBeenCalled()
expect(controller.stateManager.setTaskSettings).not.toHaveBeenCalled()
expect(controller.postStateToWebview).not.toHaveBeenCalled()
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(0)
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
expect(controller.postStateToWebview.mock.calls.length).toBe(0)
})
})
@@ -1,3 +1,4 @@
import { setCompactionStrategyGlobally } from "@cline/core"
import { Empty } from "@shared/proto/cline/common"
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
@@ -179,6 +180,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
}
if (request.compactionStrategy !== undefined) {
const strategy = request.compactionStrategy
if (strategy !== "basic" && strategy !== "agentic") {
throw new Error(`Invalid compaction strategy value: ${strategy}`)
}
setCompactionStrategyGlobally(strategy)
}
// Update custom prompt choice
if (request.customPrompt !== undefined) {
const value = request.customPrompt === "compact" ? "compact" : undefined
@@ -1,7 +1,7 @@
import { Empty } from "@shared/proto/cline/common"
import { PlanActMode, UpdateSettingsRequestCli } from "@shared/proto/cline/state"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Settings } from "@shared/storage/state-keys"
import type { Settings } from "@shared/storage/state-keys"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { ClineEnv } from "@/config"
import { Logger } from "@/shared/services/Logger"
@@ -0,0 +1,24 @@
import { Empty } from "@shared/proto/cline/common"
import type { IntentEvent } from "@shared/proto/cline/ui"
import { telemetryService } from "@/services/telemetry"
import type { Controller } from "../index"
export async function trackIntent(_controller: Controller, request: IntentEvent): Promise<Empty> {
switch (request.action) {
case "new_task_clicked":
telemetryService.captureNewTaskClicked(request.source, request.hasActiveTask)
break
case "prompt_submitted":
telemetryService.capturePromptSubmitted({
source: request.source,
hasText: request.hasText,
hasImages: request.hasImages,
hasFiles: request.hasFiles,
hasActiveTask: request.hasActiveTask,
textLength: request.textLength,
})
break
}
return Empty.create({})
}
+1
View File
@@ -123,6 +123,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand(commands.PlusButton, async () => {
const sidebarInstance = WebviewProvider.getInstance()
telemetryService.captureNewTaskClicked("activity_bar_plus", !!sidebarInstance.controller.task)
await sidebarInstance.controller.clearTask()
await sidebarInstance.controller.postStateToWebview()
await sendChatButtonClickedEvent()
@@ -4,6 +4,7 @@ import * as vscode from "vscode"
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { telemetryService } from "@/services/telemetry"
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
import { Logger } from "@/shared/services/Logger"
import { WebviewMessage } from "@/shared/WebviewMessage"
@@ -67,6 +68,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
// Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is received
this.setWebviewMessageListener(webviewView.webview)
telemetryService.capturePanelOpened("sidebar_resolved")
// Logs show up in bottom panel > Debug Console
//Logger.log("registering listener")
@@ -80,6 +82,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
webviewView.onDidChangeVisibility(
async () => {
if (this.webview?.visible) {
telemetryService.capturePanelOpened("sidebar_visible")
// View becoming visible should not steal editor focus.
await sendShowWebviewEvent(true)
}
+10
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest"
import { isClineProvider } from "@/shared/utils/cline"
import { resolveWorkspaceRootPath } from "./workspace-root"
describe("isClineProvider", () => {
it("treats both Cline account providers as Cline providers", () => {
expect(isClineProvider("cline")).toBe(true)
expect(isClineProvider("cline-pass")).toBe(true)
expect(isClineProvider("anthropic")).toBe(false)
expect(isClineProvider(undefined)).toBe(false)
})
})
describe("resolveWorkspaceRootPath", () => {
it("uses the first non-empty workspace path when available", () => {
expect(resolveWorkspaceRootPath(["", "/workspace"], "/Users/tester/Desktop")).toBe("/workspace")
+112 -9
View File
@@ -44,6 +44,7 @@ import { telemetryService } from "@/services/telemetry"
import type { ClineExtensionContext } from "@/shared/cline"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { isClineProvider } from "@/shared/utils/cline"
import { arePathsEqual, getDesktopDir } from "@/utils/path"
import { ClineAccountService } from "./account-service"
import { AuthService, LogoutReason } from "./auth-service"
@@ -53,6 +54,12 @@ import { createProviderCatalog } from "./model-catalog/catalog"
import type { Disposable, ProviderCatalog, ProviderConfigChange, ProviderConfigStore } from "./model-catalog/contracts"
import { parseProviderId } from "./model-catalog/provider-id"
import { createProviderConfigStore } from "./model-catalog/store"
import {
PROVIDER_FAILURE_ERROR_TYPE,
PROVIDER_FAILURE_PHASE,
type ProviderFailureTelemetry,
ProviderFailureTelemetryTurnGate,
} from "./provider-failure-telemetry"
import {
findVisibleCheckpointUserMessageByRun,
getCheckpointRunCountForMessage,
@@ -159,6 +166,7 @@ export class Controller {
private sessionEvents: SdkSessionEventCoordinator
private sessionHistory: SdkSessionHistoryLoader
private readonly sdkTelemetry: VscodeSdkTelemetryHandle
private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate()
private readonly providerConfigStore: ProviderConfigStore
private readonly providerCatalog: ProviderCatalog
private readonly providerConfigStoreSubscription: Disposable
@@ -302,6 +310,9 @@ export class Controller {
}
return this._terminalManager
},
onSendStart: () => {
this.beginProviderFailureTelemetryTurn()
},
onSendComplete: async () => {
await this.providerChanges.handleTurnComplete(this.mode)
@@ -313,17 +324,39 @@ export class Controller {
// A turn failed — the UI shows error recovery (Retry / Sign In / Add Credits).
this.turnStateTracker.set("error")
const errorMessage = error instanceof Error ? error.message : String(error)
const providerId = this.getSessionProviderId(sessionId) ?? this.getActiveProviderId()
const isClineAuthError =
this.isClineProviderActive() &&
isClineProvider(providerId) &&
(errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
errorMessage.toLowerCase().includes("missing api key") ||
errorMessage.toLowerCase().includes("unauthorized"))
if (isClineAuthError) {
this.captureProviderFailure({
sessionId,
error,
providerId,
errorType: PROVIDER_FAILURE_ERROR_TYPE.AUTH,
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
})
this.emitClineAuthError()
} else if (this.isClineProviderActive() && this.isClineBalanceError(errorMessage)) {
} else if (isClineProvider(providerId) && this.isClineBalanceError(errorMessage)) {
this.captureProviderFailure({
sessionId,
error,
providerId,
errorType: PROVIDER_FAILURE_ERROR_TYPE.BALANCE,
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
})
this.emitClineBalanceError(errorMessage)
} else {
this.captureProviderFailure({
sessionId,
error,
providerId,
errorType: PROVIDER_FAILURE_ERROR_TYPE.SEND_ERROR,
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
})
this.messages.emitSessionEvents(
[
{
@@ -360,7 +393,7 @@ export class Controller {
loadInitialMessages: async (sdkHost, sessionId) =>
(await this.sessionHistory.loadInitialMessages(sdkHost, sessionId)) ?? [],
buildStartSessionInput,
emitClineAuthError: () => this.emitClineAuthError(),
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
postStateToWebview: () => this.postStateToWebview(),
getTurnPhase: () => this.turnStateTracker.currentPhase,
@@ -411,7 +444,7 @@ export class Controller {
buildStartSessionInput,
resolveContextMentions: (text) => this.resolveContextMentions(text),
isClineProviderActive: () => this.isClineProviderActive(),
emitClineAuthError: () => this.emitClineAuthError(),
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
postStateToWebview: () => this.postStateToWebview(),
onResumeFailed: () => {
@@ -460,7 +493,8 @@ export class Controller {
loadInitialMessages: (reader, taskId) => this.sessionHistory.loadInitialMessages(reader, taskId),
resolveContextMentions: (text) => this.resolveContextMentions(text),
isClineProviderActive: () => this.isClineProviderActive(),
emitClineAuthError: (task) => this.emitClineAuthError(task),
emitClineAuthError: (task) => this.emitClineAuthErrorWithTelemetry(task),
captureProviderApiError: (event) => this.captureProviderFailure(event),
postStateToWebview: () => this.postStateToWebview(),
})
this.compaction = new SdkCompactionCoordinator({
@@ -486,6 +520,8 @@ export class Controller {
getTask: () => this.task,
postStateToWebview: () => this.postStateToWebview(),
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
captureProviderApiError: (event) => this.captureProviderFailure(event),
beginProviderFailureTelemetryTurn: () => this.beginProviderFailureTelemetryTurn(),
})
// Subscribe to MCP tool list changes so we can restart the SDK session
// when servers are added/removed/reconnected. The SDK's DefaultSessionBuilder
@@ -818,11 +854,78 @@ export class Controller {
}
}
private getTaskModelId(): string | undefined {
const modelId = this.task?.api?.getModel?.().id?.trim()
return modelId && modelId !== "unknown" ? modelId : undefined
}
private getSessionProviderId(sessionId?: string): string | undefined {
const activeSession = this.sessions.getActiveSession()
if (sessionId && activeSession?.sessionId !== sessionId) {
return undefined
}
const providerId =
activeSession?.startResult?.manifest?.provider?.trim() || activeSession?.startConfig?.providerId?.trim()
return providerId && providerId !== "unknown" ? providerId : undefined
}
private getSessionModelId(sessionId?: string): string | undefined {
const activeSession = this.sessions.getActiveSession()
if (sessionId && activeSession?.sessionId !== sessionId) {
return undefined
}
const modelId = activeSession?.startResult?.manifest?.model?.trim() || activeSession?.startConfig?.modelId?.trim()
return modelId && modelId !== "unknown" ? modelId : undefined
}
private beginProviderFailureTelemetryTurn(): void {
this.providerFailureTelemetryTurnGate.beginTurn()
}
/**
* Check if the active API provider is 'cline' (for current mode).
* Check if the active API provider uses Cline account auth for the current mode.
*/
private isClineProviderActive(): boolean {
return this.getActiveProviderId() === "cline"
return isClineProvider(this.getActiveProviderId())
}
private captureProviderFailure(event: ProviderFailureTelemetry): void {
const ulid = event.sessionId ?? this.task?.taskId ?? this.sessions.getActiveSession()?.sessionId
if (!ulid) {
return
}
if (
event.failurePhase === PROVIDER_FAILURE_PHASE.STREAMING &&
!this.providerFailureTelemetryTurnGate.shouldCaptureStreamingFailure()
) {
return
}
const provider = event.providerId ?? this.getSessionProviderId(event.sessionId) ?? "unknown"
const model = event.modelId ?? this.getSessionModelId(event.sessionId) ?? this.getTaskModelId() ?? "unknown"
const clineError = ClineError.transform(event.error, model, provider)
telemetryService.captureProviderApiError({
ulid,
model,
provider,
errorMessage: clineError.message || String(event.error),
errorStatus: clineError.status,
requestId: clineError.requestId,
errorType: event.errorType,
failurePhase: event.failurePhase,
})
}
private emitClineAuthErrorWithTelemetry(task?: string, sessionId?: string): void {
this.emitClineAuthError(task)
this.captureProviderFailure({
sessionId: sessionId ?? this.task?.taskId,
error: CLINE_ACCOUNT_AUTH_ERROR_MESSAGE,
providerId: this.getActiveProviderId(),
errorType: PROVIDER_FAILURE_ERROR_TYPE.AUTH,
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
})
}
/**
@@ -1140,7 +1243,7 @@ export class Controller {
const mode = this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
const config = await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle })
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
this.emitClineAuthError(editedText)
this.emitClineAuthErrorWithTelemetry(editedText)
return
}
@@ -1242,7 +1345,7 @@ export class Controller {
const historyTitle = checkpointRunCount === 1 ? restoredText : firstUserMessage?.text || restoredText
const config = restoreMessages ? await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle }) : undefined
if (config && usesClineAccountAuth(config.providerId) && !config.apiKey) {
this.emitClineAuthError(restoredText)
this.emitClineAuthErrorWithTelemetry(restoredText)
return
}
@@ -71,9 +71,11 @@ vi.mock("@shared/services/Logger", () => ({
// ---------------------------------------------------------------------------
let tempDir: string
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
vi.clearAllMocks()
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
@@ -91,6 +93,7 @@ beforeEach(() => {
})
afterEach(() => {
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
fs.rmSync(tempDir, { recursive: true, force: true })
})
@@ -378,7 +381,7 @@ describe("buildSessionConfig", () => {
const providers = [
{ providerId: "poolside", modelId: "poolside/laguna-m.1" },
{ providerId: "v0", modelId: "v0-1.5-md" },
{ providerId: "xiaomi", modelId: "mimo-v2-omni" },
{ providerId: "xiaomi", modelId: "mimo-v2.5" },
{ providerId: "zai-coding-plan", modelId: "glm-5.2" },
] as const
@@ -651,6 +654,46 @@ describe("buildSessionConfig", () => {
})
})
it("uses the configured SDK compaction strategy when auto condense is enabled", async () => {
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "agentic" })
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
if (key === "useAutoCondense") {
return true
}
if (key === "subagentsEnabled") {
return false
}
return undefined
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(config.compaction).toEqual({
enabled: true,
strategy: "agentic",
})
})
it("falls back to basic SDK compaction for an invalid stored strategy", async () => {
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "invalid" })
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
if (key === "useAutoCondense") {
return true
}
if (key === "subagentsEnabled") {
return false
}
return undefined
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(config.compaction).toEqual({
enabled: true,
strategy: "basic",
})
})
it("does not enable SDK compaction when global useAutoCondense is false", async () => {
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
+5 -1
View File
@@ -13,6 +13,7 @@ import {
type CoreSessionConfig,
getProviderAuthHandler,
type ProviderSettings,
readCompactionStrategyGlobally,
resolveProviderApiKeyFromSettings,
type StartSessionResult,
} from "@cline/core"
@@ -90,6 +91,8 @@ export interface SessionConfigInput {
export interface ActiveSession {
/** The session ID */
sessionId: string
/** The config used to start the active session. */
startConfig?: Pick<CoreSessionConfig, "providerId" | "modelId">
/** The runtime host instance managing this session (VscodeSessionHost) */
sdkHost: SdkSessionHost
/** Unsubscribe function for session events */
@@ -653,6 +656,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
const stateManager = StateManager.get()
const globalUseAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense") ?? false
const compactionStrategy = readCompactionStrategyGlobally()
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting") ?? true
const useAutoCondense = input.taskSettings?.useAutoCondense ?? globalUseAutoCondense
@@ -697,7 +701,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
? {
compaction: {
enabled: true,
strategy: "basic",
strategy: compactionStrategy,
},
}
: {}),
@@ -49,6 +49,7 @@ describe("parseProviderId", () => {
parseProviderId("poolside")
parseProviderId("v0")
parseProviderId("xiaomi")
parseProviderId("tencent-tokenhub")
expect(warnSpy).not.toHaveBeenCalled()
})
@@ -64,6 +65,7 @@ describe("isKnownProviderId", () => {
expect(isKnownProviderId(parseProviderId("poolside"))).toBe(true)
expect(isKnownProviderId(parseProviderId("v0"))).toBe(true)
expect(isKnownProviderId(parseProviderId("xiaomi"))).toBe(true)
expect(isKnownProviderId(parseProviderId("tencent-tokenhub"))).toBe(true)
})
it("returns false for a custom provider id", () => {
@@ -57,6 +57,7 @@ const KNOWN_API_PROVIDERS = {
nousResearch: true,
wandb: true,
xiaomi: true,
"tencent-tokenhub": true,
"cline-pass": true,
} satisfies Record<ApiProvider, true>
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest"
import { ProviderFailureTelemetryTurnGate } from "./provider-failure-telemetry"
describe("ProviderFailureTelemetryTurnGate", () => {
it("captures one streaming failure per active turn", () => {
const gate = new ProviderFailureTelemetryTurnGate()
gate.beginTurn()
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
expect(gate.shouldCaptureStreamingFailure()).toBe(false)
})
it("captures again when a new turn starts", () => {
const gate = new ProviderFailureTelemetryTurnGate()
gate.beginTurn()
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
expect(gate.shouldCaptureStreamingFailure()).toBe(false)
gate.beginTurn()
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
})
it("does not suppress streaming failures when no turn is active", () => {
const gate = new ProviderFailureTelemetryTurnGate()
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
})
})
@@ -0,0 +1,48 @@
export const PROVIDER_FAILURE_ERROR_TYPE = {
AUTH: "auth",
BALANCE: "balance",
SEND_ERROR: "send_error",
TASK_INIT: "task_init",
SDK_AGENT_ERROR: "sdk_agent_error",
SDK_AGENT_DONE_ERROR: "sdk_agent_done_error",
} as const
export const PROVIDER_FAILURE_PHASE = {
PREFLIGHT: "preflight",
STREAMING: "streaming",
} as const
export type ProviderFailureErrorType = (typeof PROVIDER_FAILURE_ERROR_TYPE)[keyof typeof PROVIDER_FAILURE_ERROR_TYPE]
export type ProviderFailurePhase = (typeof PROVIDER_FAILURE_PHASE)[keyof typeof PROVIDER_FAILURE_PHASE]
export type ProviderFailureTelemetry = {
sessionId?: string
error: unknown
providerId?: string
modelId?: string
errorType: ProviderFailureErrorType
failurePhase: ProviderFailurePhase
}
export class ProviderFailureTelemetryTurnGate {
private turnCounter = 0
private activeTurnId: number | undefined
private streamingFailureCapturedTurnId: number | undefined
beginTurn(): void {
this.turnCounter += 1
this.activeTurnId = this.turnCounter
}
shouldCaptureStreamingFailure(): boolean {
if (this.activeTurnId === undefined) {
return true
}
if (this.streamingFailureCapturedTurnId === this.activeTurnId) {
return false
}
this.streamingFailureCapturedTurnId = this.activeTurnId
return true
}
}
@@ -2,6 +2,7 @@ import type { CoreSessionEvent } from "@cline/core"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { MessageTranslatorState } from "./message-translator"
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "./provider-failure-telemetry"
import { SdkSessionEventCoordinator, type SdkSessionEventCoordinatorOptions } from "./sdk-session-event-coordinator"
vi.mock("@/shared/services/Logger", () => ({
@@ -135,6 +136,7 @@ describe("SdkSessionEventCoordinator", () => {
await coordinator.handleSessionEvent(event)
expect(clearTurnOutcome).toHaveBeenCalledOnce()
expect(options.beginProviderFailureTelemetryTurn).toHaveBeenCalledOnce()
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
@@ -263,6 +265,72 @@ describe("SdkSessionEventCoordinator", () => {
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
expect(options.sessions.setRunning).not.toHaveBeenCalled()
})
it("captures provider failure telemetry for SDK agent errors", async () => {
const error = new Error("provider failed")
const { coordinator, options } = makeCoordinator()
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-123",
event: {
type: "error",
error,
},
},
} as unknown as CoreSessionEvent
await coordinator.handleSessionEvent(event)
expect(options.captureProviderApiError).toHaveBeenCalledWith({
sessionId: "session-123",
error,
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_ERROR,
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
})
})
it("does not capture provider failure telemetry for SDK agent errors without an error payload", async () => {
const { coordinator, options } = makeCoordinator()
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-123",
event: {
type: "error",
},
},
} as unknown as CoreSessionEvent
await coordinator.handleSessionEvent(event)
expect(options.captureProviderApiError).not.toHaveBeenCalled()
})
it("captures provider failure telemetry when the SDK finishes a turn with reason error", async () => {
const { coordinator, options } = makeCoordinator()
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-123",
event: {
type: "done",
reason: "error",
text: "stream failed before assistant output",
iterations: 1,
},
},
} as unknown as CoreSessionEvent
await coordinator.handleSessionEvent(event)
expect(options.captureProviderApiError).toHaveBeenCalledWith({
sessionId: "session-123",
error: "stream failed before assistant output",
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
})
})
})
function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
@@ -299,6 +367,8 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
getTask: vi.fn(() => input.task),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
setTurnPhase: vi.fn(),
captureProviderApiError: vi.fn(),
beginProviderFailureTelemetryTurn: vi.fn(),
translateSessionEvent: vi.fn(() => input.translation ?? { messages: [], sessionEnded: false, turnComplete: false }),
isClineFreeModel: input.isClineFreeModel,
} as unknown as SdkSessionEventCoordinatorOptions & {
@@ -317,6 +387,8 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
}
taskHistory: SdkSessionEventCoordinatorOptions["taskHistory"] & { updateTaskUsage: ReturnType<typeof vi.fn> }
postStateToWebview: ReturnType<typeof vi.fn>
captureProviderApiError: ReturnType<typeof vi.fn>
beginProviderFailureTelemetryTurn: ReturnType<typeof vi.fn>
translateSessionEvent: ReturnType<typeof vi.fn>
messageTranslatorState: MessageTranslatorState
}
@@ -1,4 +1,4 @@
import type { CoreSessionEvent } from "@cline/core"
import type { AgentEvent, CoreSessionEvent } from "@cline/core"
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
import type { StateManager } from "@/core/storage/StateManager"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
@@ -6,6 +6,7 @@ import type { ClineApiReqInfo, TurnPhase } from "@/shared/ExtensionMessage"
import { Logger } from "@/shared/services/Logger"
import type { MessageTranslatorState, TranslationResult } from "./message-translator"
import { translateSessionEvent } from "./message-translator"
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
import type { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
import type { SdkModeCoordinator } from "./sdk-mode-coordinator"
@@ -18,6 +19,8 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
type AgentFailureTelemetry = Pick<ProviderFailureTelemetry, "sessionId" | "error" | "errorType"> | undefined
export interface SdkSessionEventCoordinatorOptions {
messageTranslatorState: MessageTranslatorState
sessions: SdkSessionLifecycle
@@ -37,6 +40,8 @@ export interface SdkSessionEventCoordinatorOptions {
* error. Optional for tests.
*/
setTurnPhase?: (phase: TurnPhase, anchorTs?: number) => void
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
beginProviderFailureTelemetryTurn?: () => void
}
export class SdkSessionEventCoordinator {
@@ -64,10 +69,20 @@ export class SdkSessionEventCoordinator {
}
const result = this.translateSessionEvent(event, this.options.messageTranslatorState)
const agentFailure = this.getAgentFailureTelemetry(event)
if (agentFailure && !this.options.messageTranslatorState.isSuppressedToolApprovalDenial(agentFailure.error)) {
this.options.captureProviderApiError?.({
sessionId: agentFailure.sessionId,
error: agentFailure.error,
errorType: agentFailure.errorType,
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
})
}
if (event.type === "pending_prompt_submitted") {
this.options.beginProviderFailureTelemetryTurn?.()
this.options.messageTranslatorState.clearTurnOutcome()
this.options.sessions.setRunning(true)
this.options.setTurnPhase?.("streaming")
this.options.setTurnPhase?.(PROVIDER_FAILURE_PHASE.STREAMING)
}
const zeroCostPromise = this.zeroCostForFreeClineModel(result)
if (zeroCostPromise) {
@@ -147,6 +162,33 @@ export class SdkSessionEventCoordinator {
}
}
private getAgentFailureTelemetry(event: CoreSessionEvent): AgentFailureTelemetry {
if (event.type !== "agent_event") {
return undefined
}
const agentEvent: AgentEvent = event.payload.event
if (agentEvent.type === "error") {
if (agentEvent.error == null) {
return undefined
}
return {
sessionId: event.payload.sessionId,
error: agentEvent.error,
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_ERROR,
}
}
if (agentEvent.type === "done" && agentEvent.reason === "error") {
const errorMessage = agentEvent.text.trim() || "SDK agent finished with error"
return {
sessionId: event.payload.sessionId,
error: errorMessage,
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
}
}
return undefined
}
private zeroCostForFreeClineModel(result: TranslationResult): Promise<void> | undefined {
const hasUsageCost = typeof result.usage?.totalCost === "number" && result.usage.totalCost !== 0
const hasMessageCost = result.messages.some((message) => {
@@ -41,6 +41,24 @@ describe("SdkSessionLifecycle", () => {
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
})
it("stores the provider and model config used to start the active session", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
const lifecycle = makeLifecycle()
await lifecycle.startNewSession({
config: {
providerId: "anthropic",
modelId: "claude-sonnet-4",
},
} as StartInput)
expect(lifecycle.getActiveSession()?.startConfig).toEqual({
providerId: "anthropic",
modelId: "claude-sonnet-4",
})
})
it("reuses the shared session host across sessions", async () => {
const sdkHost = makeSdkHost({
start: vi.fn().mockResolvedValueOnce({ sessionId: "session-1" }).mockResolvedValueOnce({ sessionId: "session-2" }),
@@ -147,6 +165,23 @@ describe("SdkSessionLifecycle", () => {
expect(lifecycle.getActiveSession()?.isRunning).toBe(false)
})
it("calls the send-start hook before sending to the SDK host", async () => {
const onSendStart = vi.fn()
const send = vi.fn().mockResolvedValue(undefined)
const sdkHost = makeSdkHost({ send })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
const lifecycle = makeLifecycle({ onSendStart })
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
await lifecycle.startNewSession({} as any)
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
lifecycle.fireAndForgetSend(sdkHost as any, "session-123", "hello")
await vi.waitFor(() => expect(send).toHaveBeenCalled())
expect(onSendStart).toHaveBeenCalledWith("session-123")
expect(onSendStart.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[0])
})
it("leaves the active session running when a message is queued", async () => {
const onSendComplete = vi.fn()
const send = vi.fn().mockResolvedValue(undefined)
@@ -428,8 +463,13 @@ describe("SdkSessionLifecycle", () => {
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
const lifecycle = makeLifecycle()
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
await lifecycle.startNewSession({ config: { sessionId: "source-session" } } as any)
await lifecycle.startNewSession({
config: {
sessionId: "source-session",
providerId: "openai",
modelId: "gpt-5",
},
} as StartInput)
const result = await lifecycle.restoreActiveSession({
sessionId: "source-session",
checkpointRunCount: 1,
@@ -437,6 +477,10 @@ describe("SdkSessionLifecycle", () => {
expect(result).toBe(restored)
expect(lifecycle.getActiveSession()?.sessionId).toBe("restored-session")
expect(lifecycle.getActiveSession()?.startConfig).toEqual({
providerId: "openai",
modelId: "gpt-5",
})
expect(sdkHost.stop).toHaveBeenCalledWith("source-session")
})
@@ -29,6 +29,7 @@ export interface SdkSessionLifecycleOptions {
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
/** Shared SDK telemetry service owned by SdkController. */
telemetry?: ITelemetryService
onSendStart?: (sessionId: string) => void
onSendComplete: (sessionId: string) => Promise<void> | void
onSendError: (error: unknown, sessionId: string) => Promise<void> | void
}
@@ -126,6 +127,12 @@ export class SdkSessionLifecycle {
})
this.activeSession = {
sessionId: startResult.sessionId,
startConfig: startInput.config
? {
providerId: startInput.config.providerId,
modelId: startInput.config.modelId,
}
: undefined,
sdkHost,
unsubscribe: () => {},
startResult,
@@ -182,6 +189,12 @@ export class SdkSessionLifecycle {
this.activeSession = {
...activeSession,
sessionId: restored.sessionId,
startConfig: input.start?.config
? {
providerId: input.start.config.providerId,
modelId: input.start.config.modelId,
}
: activeSession.startConfig,
startResult: restored.startResult,
isRunning: false,
}
@@ -324,6 +337,7 @@ export class SdkSessionLifecycle {
Logger.debug(`[SdkController] Ignoring ${label} of superseded send for session: ${sessionId}`)
return true
}
this.options.onSendStart?.(sessionId)
sdkHost
.send({
sessionId,
@@ -1,6 +1,7 @@
import type { HistoryItem } from "@shared/HistoryItem"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { StateManager } from "@/core/storage/StateManager"
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "./provider-failure-telemetry"
import { SdkTaskStartCoordinator, type SdkTaskStartCoordinatorOptions } from "./sdk-task-start-coordinator"
vi.mock("@/shared/services/Logger", () => ({
@@ -71,6 +72,7 @@ describe("SdkTaskStartCoordinator", () => {
expect(sessionId).toBeUndefined()
expect(options.emitClineAuthError).toHaveBeenCalledWith("needs auth")
expect(options.captureProviderApiError).not.toHaveBeenCalled()
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
})
@@ -81,17 +83,27 @@ describe("SdkTaskStartCoordinator", () => {
expect(sessionId).toBeUndefined()
expect(options.emitClineAuthError).toHaveBeenCalledWith("needs clinepass auth")
expect(options.captureProviderApiError).not.toHaveBeenCalled()
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
})
it("emits a plain chat error when session start fails (e.g. provider misconfigured)", async () => {
const { coordinator, options, state } = makeCoordinator()
options.sessions.startNewSession.mockRejectedValue(new Error("No model configured for provider openai"))
const error = new Error("No model configured for provider openai")
options.sessions.startNewSession.mockRejectedValue(error)
const sessionId = await coordinator.initTask("do something")
expect(sessionId).toBeUndefined()
expect(options.emitClineAuthError).not.toHaveBeenCalled()
expect(options.captureProviderApiError).toHaveBeenCalledWith({
sessionId: state.task?.taskId,
error,
providerId: "anthropic",
modelId: "model",
errorType: PROVIDER_FAILURE_ERROR_TYPE.TASK_INIT,
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
})
expect(state.task?.taskId).toEqual(expect.any(String))
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[
@@ -242,6 +254,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
isClineProviderActive: vi.fn(() => false),
emitClineAuthError: vi.fn(),
captureProviderApiError: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
} as unknown as SdkTaskStartCoordinatorOptions & {
sessions: SdkTaskStartCoordinatorOptions["sessions"] & {
@@ -266,6 +279,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
resolveContextMentions: ReturnType<typeof vi.fn>
isClineProviderActive: ReturnType<typeof vi.fn>
emitClineAuthError: ReturnType<typeof vi.fn>
captureProviderApiError: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
}
@@ -7,6 +7,7 @@ import type { Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import type { StateManager } from "@/core/storage/StateManager"
import { Logger } from "@/shared/services/Logger"
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
import type { SdkSessionLifecycle } from "./sdk-session-lifecycle"
@@ -52,6 +53,7 @@ export interface SdkTaskStartCoordinatorOptions {
resolveContextMentions: (text: string) => Promise<string>
isClineProviderActive: () => boolean
emitClineAuthError: (task?: string) => void
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
postStateToWebview: () => Promise<void>
}
@@ -67,6 +69,8 @@ export class SdkTaskStartCoordinator {
): Promise<string | undefined> {
Logger.log(`[SdkController] initTask called: "${prompt?.substring(0, 50)}"`)
let taskSessionId: string | undefined
let providerId: string | undefined
let modelId: string | undefined
try {
await this.options.clearTask()
@@ -82,6 +86,8 @@ export class SdkTaskStartCoordinator {
cwd,
mode,
})
providerId = config.providerId
modelId = config.modelId
Logger.log(
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
@@ -91,6 +97,8 @@ export class SdkTaskStartCoordinator {
Logger.warn(
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
)
// No task/session id exists yet, so this preflight auth UI path is
// intentionally not recorded as task-joinable provider error telemetry.
this.options.emitClineAuthError(prompt)
return undefined
}
@@ -141,6 +149,14 @@ export class SdkTaskStartCoordinator {
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
return taskSessionId
} catch (error) {
this.options.captureProviderApiError?.({
sessionId: taskSessionId,
error,
providerId,
modelId,
errorType: PROVIDER_FAILURE_ERROR_TYPE.TASK_INIT,
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
})
this.handleInitError(error, taskSessionId)
await this.options.postStateToWebview().catch((postError) => {
Logger.error("[SdkController] Failed to post state after init error:", postError)
@@ -109,6 +109,14 @@ export class ClineError extends Error {
})
}
public get status(): number | undefined {
return this._error.status
}
public get requestId(): string | undefined {
return this._error.request_id
}
/**
* Parses a stringified error into a ClineError instance.
*/
@@ -338,6 +338,12 @@ export class TelemetryService {
MODEL_FAVORITE_TOGGLED: "ui.model_favorite_toggled",
// Tracks when a button is clicked
BUTTON_CLICKED: "ui.button_clicked",
// Tracks when the Cline panel becomes visible
PANEL_OPENED: "ui.panel_opened",
// Tracks when the user explicitly starts a new task flow
NEW_TASK_CLICKED: "ui.new_task_clicked",
// Tracks when the user submits chat composer content
PROMPT_SUBMITTED: "ui.prompt_submitted",
// Tracks when the rules menu button is clicked
RULES_MENU_OPENED: "ui.rules_menu_opened",
},
@@ -1370,6 +1376,34 @@ export class TelemetryService {
})
}
public capturePanelOpened(source?: string) {
this.capture({
event: TelemetryService.EVENTS.UI.PANEL_OPENED,
properties: { source },
})
}
public captureNewTaskClicked(source?: string, hasActiveTask?: boolean) {
this.capture({
event: TelemetryService.EVENTS.UI.NEW_TASK_CLICKED,
properties: { source, hasActiveTask },
})
}
public capturePromptSubmitted(args: {
source?: string
hasText?: boolean
hasImages?: boolean
hasFiles?: boolean
hasActiveTask?: boolean
textLength?: number
}) {
this.capture({
event: TelemetryService.EVENTS.UI.PROMPT_SUBMITTED,
properties: args,
})
}
/**
* Records telemetry when an API provider returns an error
* @param ulid Unique identifier for the task
@@ -1386,6 +1420,8 @@ export class TelemetryService {
provider?: string
errorStatus?: number | undefined
requestId?: string | undefined
errorType?: string | undefined
failurePhase?: string | undefined
isNativeToolCall?: boolean
}) {
this.capture({
@@ -1402,12 +1438,16 @@ export class TelemetryService {
model: args.model,
provider: args.provider,
error_status: args.errorStatus,
error_type: args.errorType,
failure_phase: args.failurePhase,
})
const errorAttributes = {
ulid: args.ulid,
model: args.model,
provider: args.provider,
error_status: args.errorStatus,
error_type: args.errorType,
failure_phase: args.failurePhase,
}
const errorCount = this.incrementTaskCounter(this.taskErrorCounts, args.ulid)
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
@@ -1,6 +1,7 @@
import { describe, it } from "bun:test"
import { ApiFormat } from "@shared/proto/cline/models"
import * as assert from "assert"
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "../../../sdk/provider-failure-telemetry"
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
import { TelemetryMetadata, TelemetryService } from "../TelemetryService"
@@ -288,6 +289,8 @@ describe("TelemetryService metrics", () => {
errorMessage: "boom",
provider: "anthropic",
errorStatus: 500,
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
})
assert.strictEqual(provider.counters.length, 1)
@@ -298,6 +301,8 @@ describe("TelemetryService metrics", () => {
assert.strictEqual(entry.attributes.provider, "anthropic")
assert.strictEqual(entry.attributes.model, "claude")
assert.strictEqual(entry.attributes.error_status, 500)
assert.strictEqual(entry.attributes.error_type, PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR)
assert.strictEqual(entry.attributes.failure_phase, PROVIDER_FAILURE_PHASE.STREAMING)
assert.strictEqual(provider.histograms.length, 1)
const errorHistogram = provider.histograms[0]
assert.strictEqual(errorHistogram.name, TelemetryService.METRICS.ERRORS.PER_TASK)
@@ -306,6 +311,8 @@ describe("TelemetryService metrics", () => {
assert.strictEqual(errorHistogram.attributes.provider, "anthropic")
assert.strictEqual(errorHistogram.attributes.model, "claude")
assert.strictEqual(errorHistogram.attributes.error_status, 500)
assert.strictEqual(errorHistogram.attributes.error_type, PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR)
assert.strictEqual(errorHistogram.attributes.failure_phase, PROVIDER_FAILURE_PHASE.STREAMING)
})
it("captureTaskCompleted records completion payload with TTFT and duration histograms", () => {
@@ -109,6 +109,7 @@ export interface ExtensionState {
mcpResponsesCollapsed?: boolean
yoloModeToggled?: boolean
useAutoCondense?: boolean
compactionStrategy?: string
subagentsEnabled?: boolean
worktreesEnabled?: ClineFeatureSetting
customPrompt?: string
+1
View File
@@ -49,6 +49,7 @@ export type ApiProvider =
| "nousResearch"
| "wandb"
| "xiaomi"
| "tencent-tokenhub"
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
@@ -4,7 +4,7 @@ import { convertApiConfigurationToProto, convertProtoToApiConfiguration } from "
describe("api configuration provider conversion", () => {
it("round-trips SDK provider ids added after the legacy enum list", () => {
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "zai-coding-plan"]
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "tencent-tokenhub", "zai-coding-plan"]
for (const provider of providers) {
const proto = convertApiConfigurationToProto({
@@ -1,3 +1,4 @@
import { readFileSync, writeFileSync } from "node:fs"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
export interface OAuthCredentials {
@@ -12,6 +13,28 @@ export interface StartSessionResult {
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
export type GlobalCompactionStrategy = "basic" | "agentic"
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
try {
const settings = JSON.parse(readFileSync(process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "", "utf8"))
return settings.compactionStrategy === "agentic" ? "agentic" : "basic"
} catch {
return "basic"
}
}
export function setCompactionStrategyGlobally(compactionStrategy: GlobalCompactionStrategy): void {
const filePath = process.env.CLINE_GLOBAL_SETTINGS_PATH
if (filePath) {
let settings = {}
try {
settings = JSON.parse(readFileSync(filePath, "utf8"))
} catch {}
writeFileSync(filePath, JSON.stringify({ ...settings, compactionStrategy }))
}
}
export function truncateCommandOutput(output: string): string {
return output
}
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
const newTask = vi.fn().mockResolvedValue(undefined)
const askResponse = vi.fn().mockResolvedValue(undefined)
const condense = vi.fn().mockResolvedValue(undefined)
const trackIntent = vi.fn().mockResolvedValue(undefined)
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
@@ -18,6 +19,9 @@ vi.mock("@/services/grpc-client", () => ({
condense: (req: unknown) => condense(req),
reportBug: vi.fn().mockResolvedValue(undefined),
},
UiServiceClient: {
trackIntent: (req: unknown) => trackIntent(req),
},
}))
// Proto request factories just echo their input so we can assert on it.
@@ -25,6 +29,9 @@ vi.mock("@shared/proto/cline/task", () => ({
AskResponseRequest: { create: (x: unknown) => x },
NewTaskRequest: { create: (x: unknown) => x },
}))
vi.mock("@shared/proto/cline/ui", () => ({
IntentEvent: { create: (x: unknown) => x },
}))
vi.mock("@shared/proto/cline/common", () => ({
EmptyRequest: { create: (x: unknown) => x },
StringRequest: { create: (x: unknown) => x },
@@ -93,6 +100,8 @@ describe("useMessageHandlers — send routing", () => {
askResponse.mockResolvedValue(undefined)
condense.mockReset()
condense.mockResolvedValue(undefined)
trackIntent.mockReset()
trackIntent.mockResolvedValue(undefined)
mockTurnState = undefined
})
@@ -108,6 +117,7 @@ describe("useMessageHandlers — send routing", () => {
expect(condense).toHaveBeenCalledWith(expect.objectContaining({ value: "compact" }))
expect(newTask).not.toHaveBeenCalled()
expect(askResponse).not.toHaveBeenCalled()
expect(trackIntent).not.toHaveBeenCalled()
})
it("routes the /smol alias to the condense RPC as well", async () => {
@@ -121,6 +131,7 @@ describe("useMessageHandlers — send routing", () => {
expect(condense).toHaveBeenCalledTimes(1)
expect(newTask).not.toHaveBeenCalled()
expect(askResponse).not.toHaveBeenCalled()
expect(trackIntent).not.toHaveBeenCalled()
})
it("does not intercept /compact when there is no active task (starts a new task instead)", async () => {
@@ -133,6 +144,17 @@ describe("useMessageHandlers — send routing", () => {
expect(condense).not.toHaveBeenCalled()
expect(newTask).toHaveBeenCalledTimes(1)
expect(trackIntent).toHaveBeenCalledWith(
expect.objectContaining({
action: "prompt_submitted",
source: "chat_submit",
hasText: true,
hasImages: false,
hasFiles: false,
hasActiveTask: false,
textLength: "/compact".length,
}),
)
})
it("after a completed turn (no clineAsk), Enter continues the conversation via askResponse — NOT newTask", async () => {
@@ -148,6 +170,17 @@ describe("useMessageHandlers — send routing", () => {
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({ responseType: "messageResponse", text: "another question" }),
)
expect(trackIntent).toHaveBeenCalledWith(
expect.objectContaining({
action: "prompt_submitted",
source: "chat_submit",
hasText: true,
hasImages: false,
hasFiles: false,
hasActiveTask: true,
textLength: "another question".length,
}),
)
})
it("shows pending composer state before a follow-up askResponse resolves", async () => {
@@ -379,6 +412,17 @@ describe("useMessageHandlers — send routing", () => {
expect(newTask).toHaveBeenCalledTimes(1)
expect(askResponse).not.toHaveBeenCalled()
expect(trackIntent).toHaveBeenCalledWith(
expect.objectContaining({
action: "prompt_submitted",
source: "chat_submit",
hasText: true,
hasImages: false,
hasFiles: false,
hasActiveTask: false,
textLength: "brand new task".length,
}),
)
})
it("restores pending new-task UI state when the RPC fails", async () => {
@@ -1,9 +1,10 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/cline/task"
import { IntentEvent } from "@shared/proto/cline/ui"
import { useCallback, useRef } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
import { SlashServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
import type { ButtonActionType } from "../shared/buttonConfig"
import type { ChatState, MessageHandlers } from "../types/chatTypes"
@@ -64,6 +65,19 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
if (hasContent) {
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
let messageSent = false
const trackPromptSubmitted = (hasActiveTask: boolean) => {
UiServiceClient.trackIntent(
IntentEvent.create({
action: "prompt_submitted",
source: "chat_submit",
hasText: messageToSend.length > 0,
hasImages: images.length > 0,
hasFiles: files.length > 0,
hasActiveTask,
textLength: messageToSend.length,
}),
).catch((error) => console.error("Failed to track prompt submit:", error))
}
const clearSentMessageState = () => {
setInputValue("")
setActiveQuote(null)
@@ -84,6 +98,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
request: ReturnType<typeof AskResponseRequest.create>,
options: { showPendingMessage?: boolean } = {},
) => {
trackPromptSubmitted(true)
clearSentMessageState()
if (options.showPendingMessage) {
const afterTs = Math.max(0, ...messages.map((message) => message.ts))
@@ -118,6 +133,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
files,
})
clearSentMessageState()
trackPromptSubmitted(false)
try {
await TaskServiceClient.newTask(request)
} catch (error) {
@@ -256,9 +272,16 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
// Start a new task
const startNewTask = useCallback(async () => {
UiServiceClient.trackIntent(
IntentEvent.create({
action: "new_task_clicked",
source: "chat_new_task",
hasActiveTask: messages.length > 0,
}),
).catch((error) => console.error("Failed to track new task click:", error))
setActiveQuote(null)
await TaskServiceClient.clearTask(EmptyRequest.create({}))
}, [setActiveQuote])
}, [messages.length, setActiveQuote])
// Clear input state helper
const clearInputState = useCallback(() => {
@@ -1,8 +1,9 @@
import { IntentEvent } from "@shared/proto/cline/ui"
import { HistoryIcon, PlusIcon, PuzzleIcon, SettingsIcon, UserCircleIcon } from "lucide-react"
import { useMemo } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { TaskServiceClient } from "@/services/grpc-client"
import { TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
import { useExtensionState } from "../../context/ExtensionStateContext"
export const Navbar = () => {
@@ -17,6 +18,12 @@ export const Navbar = () => {
tooltip: "New Task",
icon: PlusIcon,
navigate: () => {
UiServiceClient.trackIntent(
IntentEvent.create({
action: "new_task_clicked",
source: "navbar",
}),
).catch((error) => console.error("Failed to track new task click:", error))
// Close the current task, then navigate to the chat view
TaskServiceClient.clearTask({})
.catch((error) => {
@@ -69,6 +69,7 @@ describe("providerSettingsRegistry", () => {
["nousResearch", "NousResearch", undefined],
["poolside", "Poolside", undefined],
["sambanova", "SambaNova", "https://docs.sambanova.ai/cloud/docs/get-started/overview"],
["tencent-tokenhub", "Tencent TokenHub", "https://cloud.tencent.com/document/product/1823/130050"],
["vercel-ai-gateway", "Vercel AI Gateway", "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai"],
["v0", "Vercel v0", undefined],
["wandb", "W&B", "https://wandb.ai"],
@@ -97,6 +97,9 @@ const GENERIC_PROVIDER_PRESENTATION_OVERRIDES: Record<string, GenericProviderPre
signupUrl: "https://wandb.ai",
},
xiaomi: {},
"tencent-tokenhub": {
signupUrl: "https://cloud.tencent.com/document/product/1823/130050",
},
"zai-coding-plan": {},
}
@@ -157,6 +160,7 @@ const FALLBACK_GENERIC_PROVIDER_NAMES = {
v0: "Vercel v0",
wandb: "W&B",
xiaomi: "Xiaomi",
"tencent-tokenhub": "Tencent TokenHub",
"zai-coding-plan": "Z.AI Coding Plan",
} as const
@@ -1,23 +1,27 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
import FeatureSettingsSection from "./FeatureSettingsSection"
const mockUpdateSetting = vi.fn()
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(() => ({
const mockExtensionState = vi.hoisted(() => ({
value: {
enableCheckpointsSetting: true,
hooksEnabled: false,
showFeatureTips: false,
mcpDisplayMode: "rich",
yoloModeToggled: false,
useAutoCondense: false,
compactionStrategy: "basic",
subagentsEnabled: false,
worktreesEnabled: { user: true, featureFlag: true },
focusChainSettings: { enabled: false, remindClineInterval: 6 },
remoteConfigSettings: {},
backgroundEditEnabled: false,
})),
},
}))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(() => mockExtensionState.value),
}))
vi.mock("../utils/settingsHandlers", () => ({
@@ -25,6 +29,15 @@ vi.mock("../utils/settingsHandlers", () => ({
}))
describe("FeatureSettingsSection", () => {
beforeEach(() => {
mockUpdateSetting.mockClear()
mockExtensionState.value = {
...mockExtensionState.value,
useAutoCondense: false,
compactionStrategy: "basic",
}
})
it("renders Hooks feature toggle", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
@@ -49,6 +62,22 @@ describe("FeatureSettingsSection", () => {
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
})
it("renders the Auto Compact Strategy setting in the Agent section", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
expect(screen.getByText("Auto Compact Strategy")).toBeTruthy()
const agentSection = container.querySelector("#agent-features")
expect(agentSection?.textContent).toContain("Basic")
})
it("disables Auto Compact Strategy when Auto Compact is off", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
const strategySelect = container.querySelector("#agent-features button[role='combobox']")
expect(strategySelect).toHaveAttribute("disabled")
})
it("calls updateSetting with hooksEnabled when toggled", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)

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