Compare commits

..

32 Commits

Author SHA1 Message Date
Dominic Cooney ca22ccf4a1 chore(vscode): remove /reportbug slash command
/reportbug survived the SDK migration in a broken, partial state: the
controller handler (reportBug.ts) just called
handleWebviewAskResponse("yesButtonClicked") unconditionally, with no
SDK-side tool ever emitting the ask:"report_bug" message it expects to
resolve, and no GitHub issue URL builder left in github-url-utils.ts
(createGitHubIssueUrl/createAndOpenGitHubIssue were dropped during the
migration). Selecting "Report bug" was effectively a no-op.

This mirrors an earlier full removal of the feature elsewhere in the
project's history (a232def05, "feat: remove /reportbug slash
command") for the same reasoning: it's a rarely-used utility that adds
system-prompt noise and slash-command clutter for a task users can
already do directly at github.com/cline/cline/issues. Rather than
rebuild the AI-driven flow (custom SDK tool + coordinator + ask/preview
+ restored URL builder) on top of the SDK architecture, remove it
entirely, consistent with that precedent.

Removed:
- Backend: reportBug.ts handler, the reportBug RPC (slash.proto) and
  REPORT_BUG value (ui.proto's ClineAsk enum, tools.ts's
  ClineDefaultTool enum), proto-conversion mapping entries.
- Webview: ReportBugPreview.tsx, the /reportbug entry in
  BASE_SLASH_COMMANDS, the report_bug button config and ask-routing
  cases (buttonConfig.ts, useMessageHandlers.ts, ChatRow.tsx), the
  App.stories.tsx story, and the rotating FeatureTip mentioning
  /reportbug.
- Docs: the /reportbug table row and section in using-commands.mdx,
  and the ide.mdx "Report issues" bullet, repointed at the GitHub
  issues URL directly.

Ran `bun run protos` to regenerate the ProtoBus/gRPC bindings
accordingly (git-ignored, not committed).
2026-07-08 19:17:51 +09:00
Dominic Cooney fe2ceeaf97 refactor(vscode): remove dead getAvailableSlashCommands RPC and cliCompatible
The getAvailableSlashCommands gRPC handler assembled base commands +
plugin commands + workflows into a SlashCommandsResponse, tagged with
a cliCompatible flag (originally meant to distinguish VS Code-only
commands like the since-removed /explain-changes). Nothing ever called
this RPC from the webview: ChatTextArea/SlashCommandMenu compute the
autocomplete list entirely through the separate slash-commands.ts
utility, driven by the ExtensionState pushed on every state update
rather than a pulled RPC response. slash-commands.ts's local
assembly is also strictly more complete (it additionally covers MCP
prompt commands, which the RPC never included).

With no consumer, cliCompatible was dead metadata: grepping the whole
repo (webview, generated hosts, IntelliJ plugin, CLI) turned up no
reads of the field outside the handler's own tests.

Remove the RPC (and its now-unused SlashCommandInfo/SlashCommandsResponse
messages) from slash.proto, delete the handler and its tests, and drop
cliCompatible from the shared SlashCommand type and BASE_SLASH_COMMANDS.
Ran `bun run protos` to regenerate the generated ProtoBus/gRPC files
accordingly (git-ignored, not committed).
2026-07-08 19:16:21 +09:00
Dominic Cooney 634cc8ac9a fix(vscode): fix compile errors in plugin slash command coordinator
CI caught two real TypeScript errors that had gone unnoticed locally
(the dev environment's @cline/core build artifacts were stale, masking
them):

1. sdk-plugin-commands.ts: createContributionRegistry() was called
   without type arguments, defaulting TMessage to `unknown`. This
   doesn't match AgentExtension's setup() signature, which expects
   Message[] (per @cline/shared), causing a type mismatch. Mirror the
   CLI's equivalent call in plugin-chat-commands.ts, which explicitly
   parameterizes <Extension, AgentTool, Message[]>.

2. SdkController.ts: emitSessionEvents(messages, event) requires two
   arguments, but the plugin-command reply path only passed one. Add
   the missing status event, matching the pattern used elsewhere in
   this file (e.g. the provider-failure error path) and in
   sdk-followup-coordinator.ts.
2026-07-08 19:15:50 +09:00
Dominic Cooney 70d2088306 fix(vscode): thread plugin slash commands into autocomplete menu and navigation
ChatTextArea/SlashCommandMenu destructure pluginSlashCommands from
ExtensionState and pass it to validateSlashCommand() for input
highlighting, but never pass it to getMatchingSlashCommands() for
arrow-key navigation, Enter/Tab selection, or the rendered
SlashCommandMenu itself. Plugin-registered commands (e.g. /goal) are
discovered correctly on the backend and shipped to the webview, but
never appear in the actual autocomplete dropdown.

Thread pluginSlashCommands through all three remaining call sites and
add it to the relevant useCallback/useLayoutEffect dependency arrays
so the menu updates once plugin discovery resolves asynchronously
after mount.
2026-07-08 14:44:38 +09:00
Dominic Cooney cf6dbdc779 feat(vscode): surface plugin commands in slash command autocomplete (CLINE-2584) 2026-07-08 14:41:31 +09:00
Dominic Cooney efc28486fd fix(vscode): bundle plugin sandbox bootstrap and surface plugin commands (CLINE-2584) 2026-07-08 14:41:30 +09:00
Dominic Cooney 90c427740d perf(sdk): stop listSessions hot loop from hanging the extension host (#11967)
* perf(sdk): stop listSessions hot loop from hanging the extension host

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

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

* refactor(sdk): strengthen session history cache patching

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* fixes

* chat

* apply

* ClinePass support

* add build instructions and use system theme

* fix: diff status

* update tool calls display

* connection updates

* lint fix

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

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

* add unit tests

* Update stale unit tests

* clarify doc string

* Update docs format

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

* Clarify prepareTurn request projection semantics

* Harden hub compaction sidecar ownership

* Handle compaction sidecar edge cases

* Address compaction sidecar review feedback

* Tighten compaction sidecar safety

* Extract atomic session file writes

* Assert compaction boundary role delimiter

* Simplify compaction source hashing

* Fix compaction smoke test type guard

* Fix async interactive runtime tests

* Avoid dangling compaction path in manifests

* Address compaction sidecar review nits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Lower live catalog default input tokens to 128000

* Lower compaction DEFAULT_MAX_INPUT_TOKENS to 128000

---------

Co-authored-by: Cline Bot <bot@cline.bot>
Co-authored-by: John Simone <john@cline.bot>
2026-07-06 10:48:51 -07:00
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
217 changed files with 16704 additions and 4214 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 }}"
+7
View File
@@ -85,3 +85,10 @@ apps/vscode/webview-ui/src/**/*.js.map
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
+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 |
+22
View File
@@ -1,5 +1,27 @@
# 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.35",
"version": "3.0.38",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+74
View File
@@ -1013,6 +1013,80 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
// CLINE-2406: when persisted Cline auth includes an accountId, the
// runtime path must call identifyTelemetryAccount(accountContext) so
// subsequent task.* and workspace.* events carry user_id.
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
};
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
clineSettings,
);
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "cline",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
expect.objectContaining({
id: "usr-abc-123",
provider: "cline",
}),
);
});
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
// identifyTelemetryAccount should not be called from the runtime path.
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
// no auth / no accountId
};
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
clineSettings,
);
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "cline",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
});
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
// CLINE-2406: identity identification from saved settings only applies
// to Cline-provider sessions; other providers use different auth flows.
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
provider: "openrouter",
model: "openai/gpt-5",
});
providerSettingsMocks.getProviderSettings.mockReturnValue({
provider: "openrouter",
model: "openai/gpt-5",
});
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "openrouter",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
+14
View File
@@ -46,6 +46,7 @@ import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
getCliTelemetryService,
identifyTelemetryAccount,
} from "./utils/telemetry";
import type { Config } from "./utils/types";
import { runConnectWizard } from "./wizards/connect";
@@ -962,6 +963,19 @@ export async function runCli(): Promise<void> {
);
let selectedProviderSettings =
providerSettingsManager.getProviderSettings(provider);
// Apply locally persisted Cline account identity so subsequent events
// (task.*, workspace.initialized) carry user_id when available.
// Note: user.extension_activated fires anonymously earlier in startup
// and cannot be retroactively updated; this is by design for
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
if (provider === "cline") {
const savedAccountId = selectedProviderSettings?.auth?.accountId;
if (savedAccountId) {
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
}
}
const persistedApiKey = getPersistedProviderApiKey(
provider,
selectedProviderSettings,
@@ -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;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import {
type ContentBlock,
formatDisplayUserInput,
type MessageWithMetadata,
normalizeUserInput,
type ToolResultContent,
type ToolUseContent,
} from "@cline/shared";
@@ -681,7 +681,7 @@ function renderContentHTML(
toolResultsMap: Map<string, ToolResultContent>,
): string {
if (typeof content === "string") {
const text = isUser ? normalizeUserInput(content) : content;
const text = isUser ? formatDisplayUserInput(content) : content;
return renderTextHTML(text);
}
@@ -689,7 +689,7 @@ function renderContentHTML(
.map((block) => {
switch (block.type) {
case "text": {
const text = isUser ? normalizeUserInput(block.text) : block.text;
const text = isUser ? formatDisplayUserInput(block.text) : block.text;
return renderTextHTML(text);
}
case "tool_use":
+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;
}
@@ -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>
@@ -11,6 +11,7 @@ import {
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
@@ -103,7 +104,9 @@ export async function handleDesktopCommand(
): Promise<unknown> {
if (command === "list_provider_catalog") {
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
}
if (command === "list_provider_models") {
const provider = String(args?.provider ?? "").trim();
@@ -165,6 +168,11 @@ export async function handleDesktopCommand(
providerId,
openExternalUrl,
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(providerSettingsManager, providerId, {
tokenSource: "oauth",
});
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
+9 -1
View File
@@ -5,6 +5,7 @@ import {
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
saveLocalProviderSettings,
} from "@cline/core";
@@ -99,7 +100,9 @@ export async function sendProviderCatalog(
peer: BrowserPeer,
): Promise<void> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
ctx.send(peer, {
type: "provider_catalog",
providers: payload.providers,
@@ -138,6 +141,11 @@ export async function runProviderOAuthLogin(
normalized,
openExternalUrl,
);
if (saved.provider !== normalized) {
markLocalProviderEnabled(providerSettingsManager, normalized, {
tokenSource: "oauth",
});
}
ctx.send(peer, {
type: "provider_oauth_login_done",
providerId: normalized,
+9 -2
View File
@@ -1,3 +1,4 @@
import { formatDisplayUserInput } from "@cline/shared";
import type {
WebviewActionSessionSummary,
WebviewChatMessage,
@@ -213,10 +214,16 @@ export function mapHistoryToWebviewMessages(
const currentToolBlockIndexes = new Map<string, number>();
let reasoningRedacted = false;
// Persisted user text arrives raw, including runtime-generated
// <user_input>/<mode_notice> wrappers -- format at this display
// boundary so the webview never renders them.
const displayText = (text: string): string =>
role === "user" ? formatDisplayUserInput(text) : text;
const contentParts = historyContentParts(record.content);
if (contentParts.length === 0) {
const text = stringifyContent(record.content ?? record.text ?? record);
pushTextBlock(blocks, textParts, messageKey, 0, text);
pushTextBlock(blocks, textParts, messageKey, 0, displayText(text));
}
for (const [partIndex, part] of contentParts.entries()) {
@@ -227,7 +234,7 @@ export function mapHistoryToWebviewMessages(
textParts,
messageKey,
partIndex,
asString(part.text) ?? asString(part.content) ?? "",
displayText(asString(part.text) ?? asString(part.content) ?? ""),
);
continue;
}
@@ -258,8 +258,8 @@ export function SettingsView({
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -386,7 +386,7 @@ export function SettingsView({
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
usesOAuth(selectedProvider)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
@@ -46,6 +46,7 @@ export interface Provider {
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
capabilities?: string[];
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
+43
View File
@@ -13,8 +13,51 @@ From `apps/examples/desktop-app/`:
- `bun run build:sidecar` - build the Bun sidecar bundle
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
- `bun run build:binary` - build desktop binary
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Shareable Desktop Packages
Tauri desktop bundles are OS-specific, so build each package on the target OS:
- macOS: `bun run package:desktop:mac`
- Windows: `bun run package:desktop:windows`
- Linux: `bun run package:desktop:linux`
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
### macOS signing & notarization, step by step
One-time keychain setup:
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
Per-build:
```bash
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
export APPLE_API_ISSUER="<issuer UUID>"
bun run package:desktop:mac
```
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 210 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
## Runtime Overview
Startup flow:
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.0",
"version": "0.0.1",
"private": true,
"scripts": {
"dev:web": "next dev webview -p 3125 --turbo",
@@ -10,6 +10,11 @@
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -0,0 +1,311 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
type DesktopPlatform = "mac" | "windows" | "linux";
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
const VALUE_FLAGS = new Set(["--platform", "--target"]);
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
const APP_NAME = "Cline Code";
const APP_ROOT = path.resolve(import.meta.dir, "..");
const BUNDLE_ROOT = path.join(
APP_ROOT,
"src-tauri",
"target",
"release",
"bundle",
);
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
process.chdir(APP_ROOT);
const validateArgs = (): void => {
const args = process.argv.slice(2);
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for ${arg}`);
}
index += 1;
continue;
}
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
continue;
}
if (arg.startsWith("--")) {
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
throw new Error(
suggestion
? `unknown option ${arg}. Did you mean ${suggestion}?`
: `unknown option ${arg}`,
);
}
throw new Error(`unexpected argument ${arg}`);
}
};
const getArgValue = (name: string): string | undefined => {
const prefix = `${name}=`;
const inline = process.argv.find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = process.argv.indexOf(name);
if (index >= 0) {
return process.argv[index + 1];
}
return undefined;
};
const hasArg = (name: string): boolean => process.argv.includes(name);
const hostPlatform = (): DesktopPlatform => {
if (process.platform === "darwin") {
return "mac";
}
if (process.platform === "win32") {
return "windows";
}
if (process.platform === "linux") {
return "linux";
}
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
};
const resolveRequestedPlatform = (): DesktopPlatform => {
const platform =
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
if (platform === "current") {
return hostPlatform();
}
if (platform === "mac" || platform === "windows" || platform === "linux") {
return platform;
}
throw new Error(
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
);
};
const sanitizeName = (value: string): string =>
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
const packageVersion = async (): Promise<string> => {
const packageJson = await Bun.file(
path.join(APP_ROOT, "package.json"),
).json();
return String(packageJson.version ?? "0.0.0");
};
const macDistributionCredentialsConfigured = (): boolean => {
const hasCertificate = Boolean(
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
);
const hasAppleIdNotarization = Boolean(
process.env.APPLE_ID &&
process.env.APPLE_PASSWORD &&
process.env.APPLE_TEAM_ID,
);
const hasApiKeyNotarization = Boolean(
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER,
);
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
};
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
const host = hostPlatform();
if (platform !== host) {
throw new Error(
[
`cannot build ${platform} desktop bundles from ${host}.`,
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
].join("\n"),
);
}
};
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
if (hostPlatform() !== "mac") {
return;
}
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
return;
}
throw new Error(
[
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
].join("\n"),
);
};
const walkFiles = (root: string): string[] => {
if (!existsSync(root)) {
return [];
}
const paths: string[] = [];
for (const entry of readdirSync(root)) {
const fullPath = path.join(root, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
paths.push(...walkFiles(fullPath));
continue;
}
paths.push(fullPath);
}
return paths;
};
const copyArtifact = (source: string, outputName: string): string => {
const destination = path.join(PACKAGE_ROOT, outputName);
rmSync(destination, { force: true, recursive: true });
cpSync(source, destination, { recursive: true });
return destination;
};
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --force --deep --sign - ${appPath}`;
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const verifySignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`spctl --assess --type execute --verbose ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const collectMacArtifacts = async (
version: string,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
if (!existsSync(appPath)) {
throw new Error(`macOS app bundle was not created at ${appPath}`);
}
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
console.warn(
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
);
await signUnsignedMacApp(appPath);
} else {
await verifySignedMacApp(appPath);
}
const arch = process.arch === "arm64" ? "arm64" : "x64";
const suffix =
allowUnsignedMac && !macDistributionCredentialsConfigured()
? "-local-unsigned"
: "";
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
const zipPath = path.join(PACKAGE_ROOT, zipName);
rmSync(zipPath, { force: true });
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
const artifacts = [zipPath];
if (!suffix) {
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
(file) => file.endsWith(".dmg"),
)) {
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
}
}
return artifacts;
};
const collectWindowsArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
.map((file) => copyArtifact(file, path.basename(file)));
const collectLinuxArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter(
(file) =>
file.endsWith(".AppImage") ||
file.endsWith(".deb") ||
file.endsWith(".rpm"),
)
.map((file) => copyArtifact(file, path.basename(file)));
const collectArtifacts = async (
platform: DesktopPlatform,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const version = await packageVersion();
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
mkdirSync(PACKAGE_ROOT, { recursive: true });
if (platform === "mac") {
return collectMacArtifacts(version, allowUnsignedMac);
}
if (platform === "windows") {
return collectWindowsArtifacts();
}
return collectLinuxArtifacts();
};
const main = async () => {
validateArgs();
const platform = resolveRequestedPlatform();
const allowUnsignedMac =
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
const skipBuild = hasArg("--skip-build");
assertCanBuildPlatform(platform);
if (platform === "mac") {
assertMacDistributionReady(allowUnsignedMac);
}
if (!skipBuild) {
await $`bun run build:binary`;
}
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
if (artifacts.length === 0) {
throw new Error(
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
);
}
console.log(`Packaged ${platform} desktop artifacts:`);
for (const artifact of artifacts) {
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
}
};
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { buildSessionConnectionUpdate } from "./chat-session";
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
});
expect(Object.hasOwn(update, "thinking")).toBe(false);
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
it("clears reasoning settings when thinking is explicitly disabled", () => {
expect(
buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: false,
}),
).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: false,
reasoningEffort: null,
thinkingBudgetTokens: null,
});
});
it("updates explicit reasoning settings without clearing omitted settings", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
reasoningEffort: "high",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
});
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
});
@@ -20,6 +20,10 @@ import type {
SidecarContext,
} from "./types";
type SessionConnectionUpdate = Parameters<
ClineCore["updateSessionConnection"]
>[1];
// ---------------------------------------------------------------------------
// Session data helpers
// ---------------------------------------------------------------------------
@@ -103,7 +107,40 @@ function isoTimestampToMs(
return Number.isFinite(parsed) ? parsed : undefined;
}
function readReasoningEffort(
value: unknown,
): "low" | "medium" | "high" | "xhigh" | undefined {
if (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
) {
return value;
}
return undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
return undefined;
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
thinking === false
? undefined
: readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens =
thinking === false
? undefined
: readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return {
sessionId: config.sessionId ?? config.session_id,
providerId: config.provider ?? config.providerId ?? "",
@@ -125,6 +162,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
config.enableAgentTeams ??
config.enable_teams ??
false,
...(thinking !== undefined ? { thinking } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
teamName: config.teamName ?? config.team_name,
missionLogIntervalSteps:
config.missionStepInterval ?? config.missionLogIntervalSteps,
@@ -136,6 +176,63 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
};
}
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
const updates: SessionConnectionUpdate = {};
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (providerId) {
updates.providerId = providerId;
}
const modelId = String(config.model ?? config.modelId ?? "").trim();
if (modelId) {
updates.modelId = modelId;
}
const apiKey =
typeof config.apiKey === "string"
? config.apiKey.trim()
: typeof config.api_key === "string"
? config.api_key.trim()
: undefined;
if (apiKey) {
updates.apiKey = apiKey;
}
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
updates.baseUrl = config.baseUrl.trim();
}
if (config.headers && typeof config.headers === "object") {
updates.headers = config.headers as Record<string, string>;
}
if (config.providerConfig && typeof config.providerConfig === "object") {
updates.providerConfig =
config.providerConfig as SessionConnectionUpdate["providerConfig"];
}
if (thinking === false) {
updates.thinking = false;
updates.reasoningEffort = null;
updates.thinkingBudgetTokens = null;
return updates;
}
if (thinking === true) {
updates.thinking = true;
}
if (reasoningEffort) {
updates.thinking = true;
updates.reasoningEffort = reasoningEffort;
}
if (thinkingBudgetTokens !== undefined) {
updates.thinking = true;
updates.thinkingBudgetTokens = thinkingBudgetTokens;
}
return updates;
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -363,6 +460,13 @@ async function handleSend(
if (!prompt) throw new Error("prompt is required");
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
if (session) {
session.config = { ...session.config, ...request.config };
}
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
+68 -1
View File
@@ -25,6 +25,7 @@ import {
listLocalProviders,
listPluginTools,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
readGlobalSettings,
@@ -35,13 +36,26 @@ import {
SqliteSessionStore,
saveLocalProviderSettings,
sendHubCommand,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
findArtifactUnderDir,
readSessionManifest,
@@ -945,7 +959,7 @@ export async function handleCommand(
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await listLocalProviders(manager);
return await listLocalProviders(manager, { isClinePassEnabled: true });
}
if (command === "list_provider_models") {
const manager = new ProviderSettingsManager();
@@ -1025,12 +1039,45 @@ export async function handleCommand(
spawned.unref();
},
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
};
}
// ── Global settings ────────────────────────────────────────────────
if (command === "get_global_settings") {
return readGlobalSettings();
}
if (command === "set_telemetry_opt_out") {
if (typeof args?.telemetry_opt_out !== "boolean") {
throw new Error("telemetry_opt_out must be a boolean");
}
setTelemetryOptOutGlobally(args.telemetry_opt_out);
return readGlobalSettings();
}
if (command === "set_auto_update_enabled") {
if (typeof args?.auto_update_enabled !== "boolean") {
throw new Error("auto_update_enabled must be a boolean");
}
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
}
if (command === "start_connector_channel") {
return await startConnectorChannel(ctx.workspaceRoot, args);
}
if (command === "stop_connector_channel") {
return await stopConnectorChannel(ctx.workspaceRoot, args);
}
// ── MCP server management ─────────────────────────────────────────
if (command === "list_mcp_servers") {
return readMcpServersResponse();
@@ -1156,6 +1203,26 @@ export async function handleCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(ctx.workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(ctx.workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, {
workspaceRoot: ctx.workspaceRoot,
});
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) {
@@ -0,0 +1,307 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename, join, normalize } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
import type { JsonRecord } from "./types";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: {
flag: string;
equals?: string;
notEquals?: string;
};
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type WebviewConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type WebviewConnectorChannelsResponse = {
available: WebviewConnectorChannel[];
active: ReturnType<typeof listActiveConnectors>;
};
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function asRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
workspaceRoot: string,
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath =
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
type: platform.type,
hint: platform.hint,
fields: platform.fields.map((field) => ({
flag: field.flag,
label: field.label,
placeholder: field.placeholder,
required: field.required,
help: field.help,
initialValue: field.initialValue,
options: field.options,
includeWhen: field.includeWhen,
})),
security: platform.security
? {
prompt: platform.security.prompt,
fields: platform.security.fields.map((field) => ({
key: field.key,
label: field.label,
placeholder: field.placeholder,
help: field.help,
requiredMessage: field.requiredMessage,
})),
}
: undefined,
}));
return { available, active: listActiveConnectors() };
}
async function runCliConnectCommand(
workspaceRoot: string,
args: string[],
): Promise<{
code: number;
stdout: string;
stderr: string;
}> {
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const code = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (exitCode) => resolve(exitCode ?? 0));
});
return { code, stdout, stderr };
}
async function waitForConnectorState(
predicate: () => boolean,
timeoutMs = 5_000,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`connector did not reach expected state within ${timeoutMs}ms`,
);
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
const rawValue = values[field.flag];
if (typeof rawValue === "string") {
fieldValues[field.flag] = rawValue.trim();
} else if (field.initialValue) {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
}
return cliArgs;
}
export async function startConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
export async function stopConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
@@ -4,6 +4,9 @@ import type { SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -14,7 +17,18 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
subscribe = subscribeMock;
dispose = vi.fn();
@@ -39,8 +53,20 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -57,6 +83,15 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -67,11 +102,20 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar-approvals",
}),
);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
@@ -148,6 +192,8 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -5,10 +5,13 @@ import { dirname } from "node:path";
import {
type AgentToolContext,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
@@ -386,6 +389,7 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
unsubscribeSessionEvents: null,
};
@@ -430,6 +434,12 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -682,10 +692,17 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(`code-sidecar:${process.pid}:${randomUUID()}`),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -703,6 +720,7 @@ export async function initializeSessionManager(
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
@@ -716,5 +734,6 @@ export async function initializeSessionManager(
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
@@ -0,0 +1,998 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir as osHomedir, platform } from "node:os";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
type MarketplaceInstallInput = {
id: string;
type: MarketplacePrimitiveType;
name?: string;
install: {
args?: string[];
env?: MarketplaceEnvVar[];
command?: string;
notes?: string;
};
};
type MarketplaceInstallResult = {
id: string;
type: LocalPrimitiveType;
status: "installed" | "uninstalled";
message: string;
details?: JsonRecord;
output?: string;
};
type MarketplaceInstallStatusResult = {
installedKeys: string[];
};
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
type SpawnCommand = (
command: string,
args: string[],
options?: SpawnOptions,
) => Promise<SpawnResult>;
type CatalogFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
type CatalogLoader = () => Promise<unknown>;
const MAX_OUTPUT_CHARS = 12_000;
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
const MARKETPLACE_CATALOG_URL =
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
"https://cline.github.io/marketplace/catalog.json";
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
const SECRET_KEY_VALUE_PATTERN =
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
const SECRET_BEARER_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
const SECRET_AUTHORIZATION_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
export async function fetchMarketplaceCatalog(
fetchImpl: CatalogFetch = fetch,
): Promise<unknown> {
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
);
}
return response.json();
}
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function readInstallInput(
args?: Record<string, unknown>,
): MarketplaceInstallInput {
const entry = readInstallRecord(args);
const install =
entry.install && typeof entry.install === "object"
? (entry.install as Record<string, unknown>)
: {};
const installArgs = toStringArray(install.args);
if (installArgs.length === 0) {
throw new Error("marketplace install args are required");
}
const env = Array.isArray(install.env)
? install.env
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null)
: undefined;
return {
id: entry.id.trim(),
type: entry.type,
name: typeof entry.name === "string" ? entry.name : undefined,
install: {
args: installArgs,
command:
typeof install.command === "string" ? install.command : undefined,
env,
notes: typeof install.notes === "string" ? install.notes : undefined,
},
};
}
function readInstallRecord(
args?: Record<string, unknown>,
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
const entry =
args?.entry && typeof args.entry === "object"
? (args.entry as Record<string, unknown>)
: (args ?? {});
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
throw new Error("marketplace entry id is required");
}
if (!isPrimitiveType(entry.type)) {
throw new Error("marketplace entry type must be mcp, skill, or plugin");
}
return entry as Record<string, unknown> & {
id: string;
type: MarketplacePrimitiveType;
};
}
function readInstallRequest(args?: Record<string, unknown>) {
const entry = readInstallRecord(args);
return {
id: entry.id.trim(),
type: entry.type,
};
}
function readLocalUninstallInput(args?: Record<string, unknown>): {
id: string;
type: LocalPrimitiveType;
name?: string;
path?: string;
} {
const type = typeof args?.type === "string" ? args.type.trim() : "";
if (
type !== "mcp" &&
type !== "skill" &&
type !== "workflow" &&
type !== "plugin"
) {
throw new Error(
"local uninstall type must be mcp, skill, workflow, or plugin",
);
}
const id =
typeof args?.id === "string" && args.id.trim().length > 0
? args.id.trim()
: typeof args?.name === "string" && args.name.trim().length > 0
? args.name.trim()
: typeof args?.path === "string" && args.path.trim().length > 0
? args.path.trim()
: "";
if (!id) {
throw new Error("local uninstall id, name, or path is required");
}
return {
id,
type,
name: typeof args?.name === "string" ? args.name.trim() : undefined,
path: typeof args?.path === "string" ? args.path.trim() : undefined,
};
}
function readInstallInputList(
args?: Record<string, unknown>,
): MarketplaceInstallInput[] {
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
return rawEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
const catalogEntries =
catalog && typeof catalog === "object"
? (catalog as Record<string, unknown>).entries
: undefined;
if (!Array.isArray(catalogEntries)) {
throw new Error("marketplace catalog entries are required");
}
return catalogEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function marketplaceEntryKey(
entry: Pick<MarketplaceInstallInput, "id" | "type">,
) {
return `${entry.type}:${entry.id}`;
}
function redactOutput(value: string): string {
const lines = value.split(/\r?\n/).map((line) => {
if (!SECRET_PATTERN.test(line)) return line;
return line
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
"$1[redacted]",
);
});
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
}
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
new Promise<SpawnResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
const child = spawn(command, args, {
...options,
env: options.env ?? process.env,
shell: options.shell ?? platform() === "win32",
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
const forceKillTimeout = setTimeout(() => {
if (!settled) {
child.kill("SIGKILL");
}
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
const timeout = setTimeout(() => {
timedOut = true;
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
child.kill("SIGTERM");
}, INSTALL_COMMAND_TIMEOUT_MS);
forceKillTimeout.unref?.();
timeout.unref?.();
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
}
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
}
});
child.once("error", (error) => {
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
reject(error);
});
child.once("close", (code, signal) => {
settled = true;
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
const result = {
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
stdout,
stderr,
};
resolve(result);
});
});
function normalizeTransport(value: string | undefined): string {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertUrl(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Invalid MCP server URL: ${value}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid MCP server URL: ${value}`);
}
}
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
const [rawName, ...rest] = args;
const name = rawName?.trim();
if (!name) {
throw new Error("MCP marketplace install requires a server name");
}
let transportType = "stdio";
const headers: Record<string, string> = {};
const targetArgs: string[] = [];
let parsingMarketplaceOptions = true;
for (let index = 0; index < rest.length; index++) {
const arg = rest[index];
if (parsingMarketplaceOptions && arg === "--") {
targetArgs.push(...rest.slice(index + 1));
break;
}
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
const next = rest[index + 1]?.trim();
if (!next) throw new Error("--transport requires a value");
transportType = normalizeTransport(next);
index++;
continue;
}
const shouldParseHeader =
parsingMarketplaceOptions ||
normalizeTransport(transportType) !== "stdio";
if (
shouldParseHeader &&
(arg === "--header" || arg?.startsWith("--header="))
) {
const rawHeader =
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
if (!rawHeader) throw new Error("--header requires a value");
const separatorIndex = rawHeader.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
const headerName = rawHeader.slice(0, separatorIndex).trim();
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
if (!headerName || !headerValue) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
headers[headerName] = headerValue;
continue;
}
parsingMarketplaceOptions = false;
targetArgs.push(arg);
}
transportType = normalizeTransport(transportType);
if (transportType === "stdio") {
if (Object.keys(headers).length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...commandArgs] = targetArgs;
if (!command?.trim()) {
throw new Error("Stdio MCP install requires a command");
}
return {
name,
transportType,
command,
args: commandArgs.length > 0 ? commandArgs : undefined,
disabled: false,
};
}
if (targetArgs.length !== 1) {
throw new Error("Remote MCP install requires exactly one URL");
}
const url = targetArgs[0]?.trim() ?? "";
assertUrl(url);
return {
name,
transportType,
url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
disabled: false,
};
}
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
return { command: wrapperPath, argsPrefix: [] };
}
const entry = process.argv[1]?.trim();
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
return { command: process.execPath, argsPrefix: [entry] };
}
return { command: "cline", argsPrefix: [] };
}
function isInsidePath(childPath: string, parentPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function resolveUserInstructionRemovalTarget(input: {
type: "skill" | "workflow";
path: string;
workspaceRoot?: string;
}): string {
const filePath = resolve(input.path);
const searchPaths =
input.type === "skill"
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
const containingRoot = searchPaths.find((root) =>
isInsidePath(filePath, root),
);
if (!containingRoot) {
throw new Error(
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
);
}
const stats = statSync(filePath, { throwIfNoEntry: false });
if (!stats?.isFile()) {
throw new Error(`${input.type} file does not exist: ${filePath}`);
}
if (input.type === "workflow") {
return filePath;
}
const skillDir = dirname(filePath);
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
}
export async function uninstallLocalPrimitive(
args?: Record<string, unknown>,
options: { workspaceRoot?: string } = {},
): Promise<MarketplaceInstallResult> {
const input = readLocalUninstallInput(args);
if (input.type === "mcp") {
const name = input.name ?? input.id;
const response = deleteMcpServer(name);
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
details: { mcp: response },
};
}
if (input.type === "plugin") {
const result = await uninstallLocalPlugin({
name: input.path ? undefined : (input.name ?? input.id),
path: input.path,
workspaceRoot: options.workspaceRoot,
});
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
details: result as unknown as JsonRecord,
};
}
if (input.type === "skill" || input.type === "workflow") {
if (!input.path) {
throw new Error(`${input.type} uninstall requires a path.`);
}
const target = resolveUserInstructionRemovalTarget({
type: input.type,
path: input.path,
workspaceRoot: options.workspaceRoot,
});
const stats = statSync(target, { throwIfNoEntry: false });
if (!stats) {
throw new Error(`${input.type} target does not exist: ${target}`);
}
rmSync(target, { recursive: stats.isDirectory(), force: true });
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${input.name ?? basename(target)}.`,
details: { path: target },
};
}
throw new Error(`Unsupported local uninstall type: ${input.type}`);
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
function sanitizeSkillSegment(value: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z0-9._]+/g, "-")
.replace(/^[.-]+|[.-]+$/g, "")
.slice(0, 255);
return sanitized || "skill";
}
function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function getOfficialPluginInstallPath(source: string): string | undefined {
const slug = source.trim();
if (!isOfficialPluginSlug(slug)) return undefined;
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
return join(
resolveClineDir(),
"plugins",
"_installed",
"official",
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
);
}
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "plugin") return false;
const [source] = entry.install.args ?? [];
if (!source) return false;
const installPath = getOfficialPluginInstallPath(source);
return Boolean(installPath && existsSync(installPath));
}
function resolveHomeDir(): string {
return (
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
);
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
const candidates = new Set<string>();
const addCandidate = (value: string | undefined) => {
const normalized = sanitizeSkillSegment(value ?? "");
if (normalized && normalized !== "skill") {
candidates.add(normalized);
}
};
addCandidate(entry.id);
addCandidate(entry.name);
const installArgs = entry.install.args ?? [];
for (let index = 0; index < installArgs.length; index++) {
const arg = installArgs[index];
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
addCandidate(installArgs[index + 1]);
index++;
continue;
}
const skillFilter = arg.split("@").at(1);
if (skillFilter) {
addCandidate(skillFilter);
}
}
return [...candidates];
}
function getGlobalSkillPaths(skillName: string): string[] {
return [
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
].filter((path, index, paths) => paths.indexOf(path) === index);
}
function ensureGlobalSkillsDirWritable(): void {
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
try {
mkdirSync(skillsDir, { recursive: true });
const probePath = join(
skillsDir,
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
);
writeFileSync(probePath, "", { flag: "wx" });
unlinkSync(probePath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
);
}
}
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
return findInstalledGlobalSkillName(entry) !== undefined;
}
function findInstalledGlobalSkillName(
entry: MarketplaceInstallInput,
): string | undefined {
if (entry.type !== "skill") return undefined;
const candidates = getSkillInstallCandidates(entry);
return candidates.find((candidate) =>
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
);
}
function hasMatchingInventoryItem(
items: unknown,
entry: MarketplaceInstallInput,
): boolean {
if (!Array.isArray(items)) return false;
const candidates = new Set([
normalizeMatchValue(entry.id),
normalizeMatchValue(entry.name),
...(entry.install.args ?? []).map(normalizeMatchValue),
]);
candidates.delete("");
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
typeof record.path === "string" ? record.path : undefined,
]
.map(normalizeMatchValue)
.filter(Boolean);
return values.some((value) => candidates.has(value));
});
}
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "mcp") return false;
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = readMcpServersResponse();
const servers = Array.isArray(response.servers) ? response.servers : [];
return servers.some((server) => {
if (!server || typeof server !== "object") return false;
const record = server as JsonRecord;
return record.name === input.name;
});
}
function isMarketplaceEntryInstalled(
entry: MarketplaceInstallInput,
inventory?: JsonRecord,
): boolean {
try {
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
if (entry.type === "plugin") {
return (
isOfficialPluginInstalled(entry) ||
hasMatchingInventoryItem(inventory?.plugins, entry)
);
}
if (entry.type === "skill") {
return isGlobalSkillInstalled(entry);
}
return false;
} catch {
return false;
}
}
function commandOutput(result: SpawnResult): string | undefined {
const output = redactOutput(
[result.stdout, result.stderr].filter(Boolean).join("\n"),
);
return output.trim().length > 0 ? output.trim() : undefined;
}
async function installSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
if (isGlobalSkillInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"add",
...(entry.install.args ?? []),
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (/\bFailed to install\b/i.test(output ?? "")) {
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
}
if (!isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
if (installArgs.length !== 1) {
throw new Error(
"Plugin marketplace installs currently support exactly one source argument.",
);
}
if (isOfficialPluginInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"install",
installArgs[0] ?? "",
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
if (entry.type === "skill") {
return installSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return installPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function uninstallMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
}
export async function installMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return installMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export async function uninstallMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return uninstallMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export function listMarketplaceInstalledEntries(
args?: Record<string, unknown>,
inventory?: JsonRecord,
): MarketplaceInstallStatusResult {
const entries = readInstallInputList(args);
const installedKeys = entries
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
.map(marketplaceEntryKey);
return { installedKeys };
}
export async function installMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return installMarketplaceEntryFromCatalog(args, options);
}
export async function uninstallMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return uninstallMarketplaceEntryFromCatalog(args, options);
}
+154
View File
@@ -0,0 +1,154 @@
import { existsSync, readFileSync } from "node:fs";
import { updateMcpSettingsFileSync } from "@cline/core";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
export function readMcpServersResponse(): JsonRecord {
const settingsPath = resolveMcpSettingsPath();
if (!existsSync(settingsPath)) {
return { settingsPath, hasSettingsFile: false, servers: [] };
}
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
const servers = parsed.mcpServers as JsonRecord | undefined;
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
const record = body as JsonRecord;
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const transportType = String(
transport?.type ?? record.transportType ?? record.type ?? "stdio",
).trim();
return {
name,
transportType,
disabled: record.disabled === true,
command:
typeof transport?.command === "string"
? transport.command
: typeof record.command === "string"
? record.command
: undefined,
args: Array.isArray(transport?.args)
? transport.args
: Array.isArray(record.args)
? record.args
: undefined,
cwd:
typeof transport?.cwd === "string"
? transport.cwd
: typeof record.cwd === "string"
? record.cwd
: undefined,
env:
transport?.env && typeof transport.env === "object"
? transport.env
: record.env && typeof record.env === "object"
? record.env
: undefined,
url:
typeof transport?.url === "string"
? transport.url
: typeof record.url === "string"
? record.url
: undefined,
headers:
transport?.headers && typeof transport.headers === "object"
? transport.headers
: record.headers && typeof record.headers === "object"
? record.headers
: undefined,
metadata: record.metadata,
};
});
return { settingsPath, hasSettingsFile: true, servers: entries };
}
export function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
}
export function ensureMcpSettingsFile(): string {
const path = resolveMcpSettingsPath();
if (!existsSync(path)) {
writeMcpServersMap({});
}
return path;
}
export function setMcpServerDisabled(
name: string,
disabled: boolean,
): JsonRecord {
// 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 current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function upsertMcpServer(input: JsonRecord): JsonRecord {
const name = String(input.name ?? "").trim();
if (!name) throw new Error("server name is required");
const previousName = String(
input.previousName ?? input.previous_name ?? "",
).trim();
const transportType = String(
input.transportType ?? input.transport_type ?? "",
).trim();
const next: JsonRecord =
transportType === "stdio"
? {
transport: {
type: "stdio",
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
},
disabled: input.disabled === true,
}
: {
transport: {
type: transportType === "sse" ? "sse" : "streamableHttp",
url: input.url,
headers: input.headers,
},
disabled: input.disabled === true,
};
// 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;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function deleteMcpServer(name: string): JsonRecord {
if (!name) throw new Error("server name is required");
// 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;
delete servers[name];
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { createFetchHandler } from "./server";
import type { SidecarContext } from "./types";
function createTestServer() {
return {
port: 3126,
upgrade: vi.fn(() => true),
};
}
function createHandler(onShutdown = vi.fn()) {
return createFetchHandler({} as SidecarContext, onShutdown);
}
describe("sidecar HTTP origin checks", () => {
it("rejects cross-origin shutdown preflight requests", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/shutdown", {
method: "OPTIONS",
headers: {
origin: "https://attacker.example",
"access-control-request-method": "POST",
},
}),
server,
);
expect(response?.status).toBe(403);
expect(response?.headers.get("access-control-allow-origin")).toBeNull();
});
it("rejects cross-origin shutdown POST requests", async () => {
const onShutdown = vi.fn();
const server = createTestServer();
const response = await createHandler(onShutdown)(
new Request("http://127.0.0.1:3126/shutdown", {
method: "POST",
headers: {
origin: "https://attacker.example",
},
}),
server,
);
expect(response?.status).toBe(403);
expect(onShutdown).not.toHaveBeenCalled();
});
it("rejects cross-origin websocket upgrades", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/transport", {
headers: {
origin: "https://attacker.example",
},
}),
server,
);
expect(response?.status).toBe(404);
expect(server.upgrade).not.toHaveBeenCalled();
});
it("allows desktop webview origins in preflight responses", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/api/marketplace/catalog", {
method: "OPTIONS",
headers: {
origin: "tauri://localhost",
"access-control-request-method": "GET",
},
}),
server,
);
expect(response?.status).toBe(204);
expect(response?.headers.get("access-control-allow-origin")).toBe(
"tauri://localhost",
);
});
});
+102 -4
View File
@@ -1,6 +1,7 @@
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { handleCommand } from "./commands";
import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import {
BunRuntime,
SIDECAR_MODE,
@@ -14,6 +15,49 @@ type SidecarServer = {
upgrade(req: Request): boolean;
};
const TRUSTED_BROWSER_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
"http://localhost:3125",
"http://127.0.0.1:3125",
]);
const JSON_HEADERS = {
"content-type": "application/json",
};
function readOrigin(req: Request): string | undefined {
const origin = req.headers.get("origin")?.trim();
return origin ? origin : undefined;
}
function isTrustedRequestOrigin(req: Request): boolean {
const origin = readOrigin(req);
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
}
function corsHeaders(req: Request): Record<string, string> {
const origin = readOrigin(req);
return {
"access-control-allow-headers": "accept, content-type",
"access-control-allow-methods": "GET, POST, OPTIONS",
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
? {
"access-control-allow-origin": origin,
vary: "Origin",
}
: {}),
};
}
function jsonHeaders(req: Request): Record<string, string> {
return {
...JSON_HEADERS,
...corsHeaders(req),
};
}
// ---------------------------------------------------------------------------
// JSON response helper
// ---------------------------------------------------------------------------
@@ -27,6 +71,29 @@ function jsonResponse(
return JSON.stringify({ type: "response", id, ok, result, error });
}
function createJsonResponse(
req: Request,
body: unknown,
status = 200,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: jsonHeaders(req),
});
}
const EMPTY_MARKETPLACE_CATALOG = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
// ---------------------------------------------------------------------------
// Bun HTTP + WebSocket server
// ---------------------------------------------------------------------------
@@ -66,13 +133,20 @@ export function startServer(
return { port: server.port };
}
function createFetchHandler(
export function createFetchHandler(
_ctx: SidecarContext,
onShutdown?: (reason?: string) => Promise<void>,
) {
return async (req: Request, server: SidecarServer) => {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
if (!isTrustedRequestOrigin(req)) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders(req) });
}
if (url.pathname === "/health") {
return new Response(
JSON.stringify({
@@ -80,15 +154,39 @@ function createFetchHandler(
mode: SIDECAR_MODE,
pid: process.pid,
}),
{ headers: { "content-type": "application/json" } },
{ headers: jsonHeaders(req) },
);
}
if (url.pathname === "/transport" && server.upgrade(req)) {
if (
url.pathname === "/transport" &&
isTrustedRequestOrigin(req) &&
server.upgrade(req)
) {
return undefined;
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(req, await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(req, {
...EMPTY_MARKETPLACE_CATALOG,
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
});
}
}
if (url.pathname === "/shutdown" && req.method === "POST") {
if (!isTrustedRequestOrigin(req)) {
return new Response(JSON.stringify({ ok: false }), {
status: 403,
headers: jsonHeaders(req),
});
}
queueMicrotask(() => {
void onShutdown?.("code_sidecar_shutdown_endpoint")
.catch((error) => {
@@ -101,7 +199,7 @@ function createFetchHandler(
.finally(() => process.exit(0));
});
return new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
headers: jsonHeaders(req),
});
}
@@ -1,6 +1,7 @@
import type {
AgentToolContext,
ClineCore,
HubServer,
NodeHubClient,
ToolApprovalResult,
} from "@cline/core";
@@ -103,6 +104,7 @@ export type SidecarContext = {
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
hubServer: HubServer | null;
workspaceRoot: string;
unsubscribeSessionEvents: (() => void) | null;
};
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Bun/JavaScriptCore requires JIT + shared executable memory under the hardened runtime -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+28 -15
View File
@@ -179,30 +179,37 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
candidates.into_iter().find(|path| path.exists())
}
fn desktop_backend_binary_name() -> String {
fn desktop_backend_binary_names() -> Vec<String> {
let extension = if cfg!(windows) { ".exe" } else { "" };
let bundled_name = format!("code-sidecar{extension}");
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
if target_triple.is_empty() {
return "code-sidecar".to_string();
return vec![bundled_name];
}
let extension = if cfg!(windows) { ".exe" } else { "" };
format!("code-sidecar-{target_triple}{extension}")
vec![
bundled_name,
format!("code-sidecar-{target_triple}{extension}"),
]
}
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
if cfg!(debug_assertions) {
return None;
}
let binary_name = desktop_backend_binary_name();
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let current_exe = std::env::current_exe().ok();
let candidates = [
explicit,
Some(
let mut candidates = Vec::new();
if let Some(path) = explicit {
candidates.push(path);
}
for binary_name in desktop_backend_binary_names() {
candidates.push(
PathBuf::from(&context.workspace_root)
.join("apps")
.join("examples")
@@ -210,17 +217,23 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
.join("src-tauri")
.join("bin")
.join(&binary_name),
),
current_exe
);
if let Some(path) = current_exe
.as_ref()
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name))),
current_exe.as_ref().and_then(|path| {
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
{
candidates.push(path);
}
if let Some(path) = current_exe.as_ref().and_then(|path| {
path.parent()
.and_then(|parent| parent.parent())
.map(|parent| parent.join("Resources").join(&binary_name))
}),
];
candidates.into_iter().flatten().find(|path| path.exists())
}) {
candidates.push(path);
}
}
candidates.into_iter().find(|path| path.exists())
}
fn ensure_desktop_backend_started(
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.1.0",
"version": "0.0.1",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -33,6 +33,10 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"macOS": {
"entitlements": "entitlements.plist",
"hardenedRuntime": true
}
}
}
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
@@ -0,0 +1,41 @@
const MARKETPLACE_CATALOG_URL =
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
"https://cline.github.io/marketplace/catalog.json";
export const dynamic = "force-static";
const EMPTY_MARKETPLACE_CATALOG = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
export async function GET() {
try {
const response = await fetch(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
return Response.json({
...EMPTY_MARKETPLACE_CATALOG,
error:
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
});
}
return Response.json(await response.json());
} catch (error) {
return Response.json({
...EMPTY_MARKETPLACE_CATALOG,
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
});
}
}
@@ -9,39 +9,73 @@
--font-geist-mono:
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
monospace;
--background: oklch(0.13 0.005 260);
--foreground: oklch(0.93 0 0);
--card: oklch(0.16 0.005 260);
--card-foreground: oklch(0.93 0 0);
--popover: oklch(0.16 0.005 260);
--popover-foreground: oklch(0.93 0 0);
--primary: oklch(0.75 0.12 165);
--primary-foreground: oklch(0.13 0.005 260);
--secondary: oklch(0.22 0.005 260);
--secondary-foreground: oklch(0.85 0 0);
--muted: oklch(0.2 0.005 260);
--muted-foreground: oklch(0.55 0 0);
--accent: oklch(0.22 0.01 260);
--accent-foreground: oklch(0.93 0 0);
--destructive: oklch(0.55 0.2 25);
--destructive-foreground: oklch(0.93 0 0);
--border: oklch(0.25 0.005 260);
--input: oklch(0.2 0.005 260);
--ring: oklch(0.75 0.12 165);
--chart-1: oklch(0.75 0.12 165);
--chart-2: oklch(0.65 0.15 250);
--chart-3: oklch(0.7 0.15 50);
--chart-4: oklch(0.65 0.18 320);
--chart-5: oklch(0.6 0.12 200);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem;
--sidebar: oklch(0.11 0.005 260);
--sidebar-foreground: oklch(0.85 0 0);
--sidebar-primary: oklch(0.75 0.12 165);
--sidebar-primary-foreground: oklch(0.13 0.005 260);
--sidebar-accent: oklch(0.18 0.008 260);
--sidebar-accent-foreground: oklch(0.93 0 0);
--sidebar-border: oklch(0.22 0.005 260);
--sidebar-ring: oklch(0.75 0.12 165);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
@@ -104,8 +138,17 @@
}
@layer components {
.markdown {
@apply leading-relaxed;
}
.markdown * {
@apply text-sm;
@apply text-sm leading-relaxed;
}
.markdown + .markdown {
@apply mt-2;
}
.markdown p {
@apply my-2 first:mt-0 last:mb-0;
}
.markdown a {
@apply underline;
+67 -8
View File
@@ -22,17 +22,21 @@ import {
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import { DiffView } from "@/components/views/chat/diff-view";
import { SessionsView } from "@/components/views/sessions/sessions-view";
import { SettingsView } from "@/components/views/settings/settings-view";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
getSessionMetadataTitle,
type SessionHistoryItem,
type SessionMetadata,
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
function makeThreadId(): string {
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
@@ -70,17 +74,24 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
const [view, setView] = useState<"chat" | "diff" | "settings">("chat");
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
const [threads, setThreads] = useState<Thread[]>(() => [
{ id: makeThreadId() },
]);
const [activeThreadId, setActiveThreadId] = useState<string>(
() => threads[0]?.id,
);
useEffect(() => {
syncHubTheme();
return watchSystemHubTheme();
}, []);
const handleNewThread = useCallback(() => {
const id = makeThreadId();
setThreads((prev) => [...prev, { id }]);
setActiveThreadId(id);
setView("chat");
}, []);
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
@@ -98,6 +109,7 @@ export default function Home() {
return [...prev, { id: threadId, historySession: session }];
});
setActiveThreadId(threadId);
setView("chat");
}, []);
const handleDeleteSession = useCallback(
@@ -176,6 +188,12 @@ export default function Home() {
?.sessionId ?? null;
const activeThread =
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
const sessionHistory = useSessionHistory({
activeSessionId: activeHistorySessionId,
onDeleteSession: handleDeleteSession,
onOpenSession: handleOpenSession,
onUpdateSessionMetadata: handleUpdateSessionMetadata,
});
return (
<>
@@ -188,13 +206,18 @@ export default function Home() {
<AgentSidebar
activeSessionId={activeHistorySessionId}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
sessionHistory={sessionHistory}
setView={setView}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{activeThread ? (
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
/>
) : activeThread ? (
<div className="flex min-h-0 flex-1 flex-col">
<ChatThreadPane
key={activeThread.id}
@@ -241,6 +264,7 @@ function ChatThreadPane({
sessionId,
status,
chatTransportState,
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
config,
@@ -454,7 +478,12 @@ function ChatThreadPane({
async (preferredWorkspace?: string) => {
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces(results);
setWorkspaces((current) =>
current.length === results.length &&
current.every((workspace, index) => workspace === results[index])
? current
: results,
);
} finally {
setWorkspacesLoaded(true);
}
@@ -595,6 +624,26 @@ function ChatThreadPane({
await sendPrompt(trimmed, toSend);
}, [pendingAttachments, promptInput, sendPrompt]);
const handleReasoningChange = useCallback(
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
setConfig((prev) => {
if (
prev.thinking === next.thinking &&
prev.reasoningEffort === next.reasoningEffort
) {
return prev;
}
return {
...prev,
thinking: next.thinking,
reasoningEffort:
next.thinking === false ? undefined : next.reasoningEffort,
};
});
},
[setConfig],
);
const handleUndoQueuedPrompt = useCallback(
async (item: PromptInQueue) => {
const removed = await removePromptInQueue(item.id);
@@ -827,9 +876,7 @@ function ChatThreadPane({
workspaceRoot: resolvedWorkspaceRoot,
workspaces,
listWorkspaces,
refreshWorkspaces: async () => {
await refreshWorkspaces();
},
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
}),
@@ -851,8 +898,17 @@ function ChatThreadPane({
<div className="flex h-full flex-1 flex-col items-center justify-center gap-3 bg-background text-foreground">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<p className="text-sm text-muted-foreground">
{chatTransportState !== "connected" ? "Connecting..." : "Loading..."}
{chatTransportState === "unavailable"
? "Desktop backend unavailable"
: chatTransportState !== "connected"
? "Connecting..."
: "Loading..."}
</p>
{chatTransportError ? (
<p className="max-w-xl px-6 text-center text-xs text-muted-foreground">
{chatTransportError}
</p>
) : null}
</div>
);
}
@@ -959,6 +1015,7 @@ function ChatThreadPane({
}))
}
onPromptInputChange={setPromptInput}
onReasoningChange={handleReasoningChange}
onSteerPromptInQueue={(promptId) => {
void steerPromptInQueue(promptId);
}}
@@ -996,8 +1053,10 @@ function ChatThreadPane({
promptsInQueue={promptsInQueue}
promptInput={promptInput}
provider={config.provider}
reasoningEffort={config.reasoningEffort}
status={status}
summary={summary}
thinking={config.thinking}
/>
</div>
</div>
@@ -80,7 +80,7 @@ export function AgentHeader({
const triggerDeleteSession = () => onDeleteSession?.();
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-card px-4">
<header className="flex h-12 items-center justify-between px-4">
{/* Left: thread title */}
<div className="flex items-center gap-2">
<span
@@ -179,7 +179,7 @@ export function AgentHeader({
type="button"
variant="secondary"
>
<span className="text-primary">+{additions}</span>
<span className="text-chart-2">+{additions}</span>
<span className="text-destructive">-{deletions}</span>
</Button>
{/* New Chat Button */}
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer cursor-pointer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer cursor-pointer data-[state=checked]:bg-primary/20 data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
@@ -25,7 +25,7 @@ import {
} from "@/components/ui/combobox";
import { useWorkspace } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
@@ -66,19 +66,62 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.3-codex"],
"openai-native": ["gpt-5.5"],
openrouter: ["anthropic/claude-sonnet-4.6"],
gemini: ["gemini-2.5-pro"],
gemini: ["gemini-3-pro-latest"],
};
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.3-codex"],
"openai-native": ["gpt-5.5"],
openrouter: ["anthropic/claude-sonnet-4.6"],
gemini: ["gemini-2.5-pro"],
gemini: ["gemini-3-pro-latest"],
};
type ReasoningEffort = NonNullable<ChatSessionConfig["reasoningEffort"]>;
type ReasoningEffortOption = {
label: string;
value: "none" | ReasoningEffort;
};
const DEFAULT_REASONING_EFFORT: ReasoningEffortOption = {
label: "Low",
value: "low",
};
const EFFORT_LEVELS: ReasoningEffortOption[] = [
{ label: "None", value: "none" },
DEFAULT_REASONING_EFFORT,
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
{ label: "Extra", value: "xhigh" },
];
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
const PROMPT_INPUT_FOCUSED_ROWS = 5;
function resolveEffortIndex(
thinking: ChatSessionConfig["thinking"],
reasoningEffort: ChatSessionConfig["reasoningEffort"],
): number {
if (thinking === false) {
return 0;
}
const index = EFFORT_LEVELS.findIndex(
(option) => option.value === reasoningEffort,
);
return index >= 0 ? index : 1;
}
function buildReasoningConfig(
option: ReasoningEffortOption,
): Pick<ChatSessionConfig, "thinking" | "reasoningEffort"> {
if (option.value === "none") {
return { thinking: false, reasoningEffort: undefined };
}
return { thinking: true, reasoningEffort: option.value };
}
function hasReasoningCapability(
providerReasoningModels: Record<string, string[]>,
provider: string,
@@ -149,12 +192,17 @@ type ChatInputBarProps = {
provider: string;
model: string;
mode: "act" | "plan";
thinking: ChatSessionConfig["thinking"];
reasoningEffort: ChatSessionConfig["reasoningEffort"];
gitBranch: string;
promptInput: string;
onPromptInputChange: (value: string) => void;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModeToggle: () => void;
onReasoningChange: (
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
) => void;
onRefreshGitBranch: () => void;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
@@ -183,12 +231,15 @@ export function ChatInputBar({
provider,
model,
mode,
thinking,
reasoningEffort,
gitBranch,
promptInput,
onPromptInputChange,
onProviderChange,
onModelChange,
onModeToggle,
onReasoningChange,
onRefreshGitBranch,
onListGitBranches,
onSwitchGitBranch,
@@ -219,10 +270,9 @@ export function ChatInputBar({
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
);
const canSend = hasDraft;
const effortLevels = ["Low", "Medium", "High"] as const;
const [effortIndex, setEffortIndex] = useState(1);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [promptInputFocused, setPromptInputFocused] = useState(false);
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
const [mentionOpen, setMentionOpen] = useState(false);
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
@@ -258,13 +308,35 @@ export function ChatInputBar({
}
return `${total.toLocaleString()} tokens`;
}, [summary.tokensIn, summary.tokensOut]);
const effortLabel = effortLevels[effortIndex];
const effortIndex = useMemo(
() => resolveEffortIndex(thinking, reasoningEffort),
[reasoningEffort, thinking],
);
const effortLabel = modelSupportsReasoning
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
: "None";
const handleEffortCycle = useCallback(() => {
if (!modelSupportsReasoning) {
return;
}
setEffortIndex((current) => (current + 1) % effortLevels.length);
}, [effortLevels.length, modelSupportsReasoning]);
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
if (!nextOption) {
return;
}
onReasoningChange(buildReasoningConfig(nextOption));
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
useEffect(() => {
if (!modelSupportsReasoning) {
if (thinking !== false || reasoningEffort !== undefined) {
onReasoningChange({ thinking: false, reasoningEffort: undefined });
}
return;
}
if (thinking === undefined && reasoningEffort === undefined) {
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
}
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
setEditingQueuedPromptId(item.id);
@@ -330,20 +402,6 @@ export function ChatInputBar({
}
}, [cancelQueuedPromptEdit, editingQueuedPromptId, promptsInQueue]);
useEffect(() => {
const input = promptInputRef.current;
if (!input) {
return;
}
input.style.height = "0px";
const styles = window.getComputedStyle(input);
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
const maxHeight = lineHeight * 10;
const nextHeight = Math.min(input.scrollHeight, maxHeight);
input.style.height = `${nextHeight}px`;
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
}, []);
useEffect(() => {
const nextMention = getActiveMention(promptInput, cursorIndex);
setActiveMention(nextMention);
@@ -748,7 +806,7 @@ export function ChatInputBar({
)}
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
<textarea
className="max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
onChange={(e) => {
onPromptInputChange(e.target.value);
setCursorIndex(
@@ -760,6 +818,8 @@ export function ChatInputBar({
e.currentTarget.selectionStart ?? promptInput.length,
)
}
onBlur={() => setPromptInputFocused(false)}
onFocus={() => setPromptInputFocused(true)}
onKeyDown={(e) => {
// Slash command menu takes priority when open.
if (slashOpen && filteredSlashCommands.length > 0) {
@@ -835,10 +895,14 @@ export function ChatInputBar({
placeholder={
isBusy
? "Agent is working... submit to queue another message"
: "Enter your question or type / for workflow or @ to attach files"
: "Enter your question or type / for commands or @ for context"
}
ref={promptInputRef}
rows={1}
rows={
promptInputFocused
? PROMPT_INPUT_FOCUSED_ROWS
: PROMPT_INPUT_COLLAPSED_ROWS
}
value={promptInput}
/>
</div>
@@ -3,19 +3,22 @@
import {
AlertCircle,
Bot,
BrainIcon,
Check,
ChevronDown,
ChevronRight,
Clock3,
Copy,
FileEdit,
FileIcon,
FileSearch,
GitBranch,
Loader2,
MessagesSquare,
RotateCcw,
Search,
ShieldAlert,
Terminal,
SplitIcon,
SquareTerminalIcon,
UndoIcon,
} from "lucide-react";
import {
memo,
@@ -28,6 +31,7 @@ import {
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
import { parseApplyPatchInput } from "@/lib/session-diff";
import { cn } from "@/lib/utils";
import { MemoizedMarkdown } from "../../ui/markdown";
import { normalizeTitle } from "../../utils";
@@ -36,7 +40,11 @@ import { WelcomeScreen } from "./welcome-chat";
type ChatMessagesProps = {
sessionId: string | null;
status: ChatSessionStatus;
chatTransportState?: "connecting" | "reconnecting" | "connected";
chatTransportState?:
| "connecting"
| "reconnecting"
| "connected"
| "unavailable";
isSessionSwitching?: boolean;
provider: string;
model: string;
@@ -365,10 +373,10 @@ function ChatMessagesImpl({
return (
<div className="relative h-full min-h-0 min-w-0">
<div
className="h-full min-h-0 min-w-0 overflow-y-auto"
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
ref={scrollAreaRef}
>
<div className="relative mx-auto w-full px-6 py-6">
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
{showIdleDetails ? (
<WelcomeScreen
provider={provider}
@@ -377,7 +385,7 @@ function ChatMessagesImpl({
quickActions={[]}
/>
) : (
<div className="flex flex-col gap-2 w-full h-full">
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
{pendingToolApprovals.length > 0 ? (
<ToolApprovalPanel
items={pendingToolApprovals}
@@ -474,7 +482,9 @@ function ChatMessagesImpl({
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{chatTransportState === "reconnecting"
? "Reconnecting chat..."
: "Connecting chat..."}
: chatTransportState === "unavailable"
? "Chat backend unavailable"
: "Connecting chat..."}
</div>
) : null}
{shouldShowErrorBanner ? (
@@ -569,7 +579,7 @@ function ToolApprovalPanel({
Request {item.requestId}
{item.iteration != null ? ` · Iteration ${item.iteration}` : ""}
</div>
<pre className="mt-2 max-h-44 overflow-auto rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
<pre className="mt-2 max-h-44 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
{formatApprovalInput(item.input)}
</pre>
{error ? (
@@ -722,6 +732,17 @@ function MessageBubble({
const isUser = message.role === "user";
const isError = message.role === "error";
const checkpoint = message.meta?.checkpoint;
const shouldRenderAssistantActions =
message.role === "assistant" &&
!isStreaming &&
!isError &&
Boolean(onCopyRawText || onForkSession);
const shouldRenderUserActions =
isUser && Boolean(onCopyRawText || checkpoint);
const keepUserActionsVisible = restorePending || Boolean(restoreError);
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
const hiddenActionButtonsClassName =
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
if (message.role === "tool") {
return <ToolMessageBlock message={message} />;
@@ -732,71 +753,106 @@ function MessageBubble({
return (
<div
className={cn("flex", isUser ? "justify-end" : "justify-start w-full")}
className={cn(
"flex min-w-0",
isUser ? "justify-end" : "w-full justify-start",
)}
>
<div
className={cn(
"space-y-2 pl-3 text-sm",
isUser && "bg-card text-foreground/80 max-w-[50%]",
!isUser && !isError && "text-foreground w-full",
"group max-w-full min-w-0 wrap-break-word text-sm",
isUser && "flex max-w-[50%] flex-col items-end gap-1",
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
!isUser && !isError && "text-foreground",
isError &&
"bg-destructive/10 border border-destructive/40 text-destructive",
)}
>
{isStreaming && message.role === "assistant" ? (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="whitespace-pre-wrap">
{normalizedContent || " "}
</div>
</>
) : (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<MemoizedMarkdown
content={normalizedContent || " "}
id={message.id}
/>
</>
)}
{isUser && checkpoint ? (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-end gap-2">
<Button
className="h-7 px-2 text-xs"
onClick={onCopyRawText}
size="sm"
type="button"
variant="outline"
>
<Copy className="h-3.5 w-3.5" />
{wasCopied ? "Copied" : "Copy"}
</Button>
<Button
className="h-7 px-2 text-xs"
disabled={restoreDisabled || restorePending}
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
size="sm"
type="button"
variant="outline"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
<div
className={cn(
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
isUser && "rounded-sm bg-card p-2 text-foreground/80",
)}
>
{isStreaming && message.role === "assistant" ? (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
{normalizedContent || " "}
</div>
</>
) : (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
<MemoizedMarkdown
content={normalizedContent || " "}
id={message.id}
/>
</div>
</>
)}
</div>
{shouldRenderUserActions ? (
<div className="space-y-1">
<div className="flex h-6 items-center justify-end">
<div
className={cn(
"flex items-center justify-end gap-2",
keepUserActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
Undo
</Button>
>
{onCopyRawText ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label={
wasCopied ? "Copied user message" : "Copy user message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy message"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
) : null}
{checkpoint ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label="Restore checkpoint"
disabled={restoreDisabled || restorePending}
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
size="sm"
title="Restore checkpoint"
type="button"
variant="ghost"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UndoIcon className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>
</div>
{restoreError ? (
<div className="text-right text-xs text-destructive">
@@ -805,30 +861,61 @@ function MessageBubble({
) : null}
</div>
) : null}
{!isUser &&
!isError &&
!isStreaming &&
message.role === "assistant" &&
onForkSession ? (
<div className="mt-1 flex items-center gap-1">
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
disabled={forkPending}
onClick={onForkSession}
size="sm"
title="Fork session — copy full message history into a new session"
type="button"
variant="ghost"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<GitBranch className="h-3 w-3" />
{shouldRenderAssistantActions ? (
<div className="flex h-6 items-center hidden">
<div
className={cn(
"flex items-center gap-0",
keepAssistantActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
</Button>
{forkError ? (
<span className="text-[11px] text-destructive">{forkError}</span>
) : null}
>
{onCopyRawText ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy raw assistant output"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
) : null}
{onForkSession ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label="Fork session"
disabled={forkPending}
onClick={onForkSession}
size="sm"
title="Fork session - copy full message history into a new session"
type="button"
variant="ghost"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SplitIcon className="h-3 w-3" />
)}
</Button>
) : null}
{forkError ? (
<span className="text-[11px] text-destructive">
{forkError}
</span>
) : null}
</div>
</div>
) : null}
</div>
@@ -850,17 +937,18 @@ function ReasoningBlock({
}
return (
<div className="mb-2">
<div className="my-2">
<Button
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
<BrainIcon className="size-4" />
Thinking
</Button>
{expanded ? (
<div className="mt-1 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
{displayContent}
</div>
) : null}
@@ -878,6 +966,10 @@ type ToolPayload = {
type ToolSummary = {
label: string;
details: string[];
diff?: {
additions: number;
deletions: number;
};
};
function pruneRequestMap<T extends string>(
@@ -966,7 +1058,12 @@ function classifyTool(
].includes(normalized)
)
return "exploration";
if (["editor", "edit_file", "edit"].includes(normalized)) return "file-edit";
if (
["editor", "edit_file", "edit", "apply_patch", "apply-patch"].includes(
normalized,
)
)
return "file-edit";
if (["bash", "run_commands"].includes(normalized)) return "bash";
if (["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized))
return "spawn";
@@ -985,6 +1082,62 @@ function asStringArray(value: unknown): string[] {
);
}
/**
* read_files accepts many input shapes: { files: [{ path }] }, { files: path },
* { file_paths: [...] }, { paths: [...] }, a bare request, an array, or a string.
*/
function extractReadFilePaths(input: unknown): string[] {
const out: string[] = [];
const push = (value: unknown) => {
if (typeof value === "string" && value.length > 0) {
out.push(value);
return;
}
const record = asRecord(value);
if (record && typeof record.path === "string" && record.path.length > 0) {
out.push(record.path);
}
};
const record = asRecord(input);
const candidates =
record?.files ?? record?.file_paths ?? record?.paths ?? record ?? input;
if (Array.isArray(candidates)) {
for (const candidate of candidates) {
push(candidate);
}
} else {
push(candidates);
}
return out;
}
/**
* run_commands entries can be shell strings or structured { command, args }.
*/
function extractCommands(input: unknown): string[] {
const inputObject = asRecord(input);
const raw = Array.isArray(inputObject?.commands)
? inputObject.commands
: typeof inputObject?.command === "string"
? [inputObject.command]
: typeof input === "string"
? [input]
: [];
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string" && entry.length > 0) {
out.push(entry);
continue;
}
const record = asRecord(entry);
if (record && typeof record.command === "string") {
const args = asStringArray(record.args);
out.push([record.command, ...args].join(" "));
}
}
return out;
}
function toDisplayPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts.at(-1) || path;
@@ -1025,10 +1178,10 @@ function buildToolSummary(
const inputObject = asRecord(input);
if (["read_files", "file_read", "file-read"].includes(normalized)) {
const files = asStringArray(inputObject?.file_paths);
const files = extractReadFilePaths(input);
if (files.length > 0) {
return {
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(files.length, "file")}`,
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
details: files.map(
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
),
@@ -1047,14 +1200,8 @@ function buildToolSummary(
}
if (["run_commands", "bash"].includes(normalized)) {
const commands = asStringArray(inputObject?.commands);
if (commands.length === 1) {
return {
label: `${inProgress ? "Running" : "Ran"} ${commands[0]}`,
details: [commands[0]],
};
}
if (commands.length > 1) {
const commands = extractCommands(input);
if (commands.length > 0) {
return {
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
details: commands.map((command) => command.trim()),
@@ -1084,9 +1231,44 @@ function buildToolSummary(
}
}
if (["apply_patch", "apply-patch"].includes(normalized)) {
const patchText =
typeof input === "string"
? input
: typeof inputObject?.input === "string"
? inputObject.input
: "";
const fileDiffs = patchText ? parseApplyPatchInput(patchText) : [];
if (fileDiffs.length > 0) {
const additions = fileDiffs.reduce((sum, d) => sum + d.additions, 0);
const deletions = fileDiffs.reduce((sum, d) => sum + d.deletions, 0);
return {
label: `${inProgress ? "Editing" : "Edited"} ${pluralize(fileDiffs.length, "file")}`,
diff: { additions, deletions },
details: fileDiffs.map(
(d) =>
`${inProgress ? "Editing" : "Edited"} ${toDisplayPath(d.path)} +${d.additions} -${d.deletions}`,
),
};
}
return {
label: inProgress ? "Applying patch" : "Applied patch",
details: [],
};
}
if (["editor", "edit_file", "edit"].includes(normalized)) {
// Current editor schema has no `command`; derive it from the input shape.
const command =
typeof inputObject?.command === "string" ? inputObject.command : "edit";
typeof inputObject?.command === "string"
? inputObject.command
: inputObject?.insert_line != null
? "insert"
: typeof inputObject?.old_text === "string"
? "str_replace"
: typeof inputObject?.new_text === "string"
? "create"
: "edit";
const path =
typeof inputObject?.path === "string"
? toDisplayPath(inputObject.path)
@@ -1107,14 +1289,12 @@ function buildToolSummary(
: command === "insert"
? "Inserted"
: "Edited";
// The label already carries all the information; no expandable details.
const detail = `${action} ${path}`;
if (diff) {
return {
label: `${detail} +${diff.additions} -${diff.deletions}`,
details: [detail],
};
return { label: detail, diff, details: [] };
}
return { label: detail, details: [detail] };
return { label: detail, details: [] };
}
const query =
@@ -1162,13 +1342,17 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
hookEventName === "history_tool_use" ||
(Boolean(payload) && payload?.result == null && !payload?.isError);
const kind = classifyTool(toolName);
const Icon =
kind === "exploration"
const isFileRead = ["read_files", "file_read", "file-read"].includes(
toolName.toLowerCase(),
);
const Icon = isFileRead
? FileIcon
: kind === "exploration"
? Search
: kind === "file-edit"
? FileEdit
: kind === "bash"
? Terminal
? SquareTerminalIcon
: kind === "spawn"
? Bot
: FileSearch;
@@ -1180,39 +1364,52 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
const hasExpandedSections =
details.length > 1 || Boolean(inputPreview || resultPreview);
details.length > 0 || Boolean(inputPreview || resultPreview);
return (
<div className="flex justify-start w-full">
<div className={cn("w-full rounded-xl text-xs")}>
<div className="my-2 flex w-full min-w-0 justify-start">
<div
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
>
<Button
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
{payload?.isError ? (
<AlertCircle className="size-3 text-destructive/80" />
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-3" />
<Icon className="size-4" />
)}
<span>{summary.label}</span>
<span className="min-w-0 wrap-break-word">{summary.label}</span>
{summary.diff ? (
<span className="shrink-0 font-mono text-xs">
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
<span className="text-destructive">
-{summary.diff.deletions}
</span>
</span>
) : null}
{hasExpandedSections ? (
<span className="text-muted-foreground">
<span className="shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="size-3" />
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-3" />
<ChevronRight className="size-4" />
)}
</span>
) : null}
</Button>
{expanded ? (
<div className="pl-8 text-muted-foreground">
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
{hasExpandedSections ? (
<div className="space-y-1">
{details.map((detail) => (
<div className="text-xxs" key={`${message.id}_${detail}`}>
<div
className="wrap-break-word"
key={`${message.id}_${detail}`}
>
{detail}
</div>
))}
@@ -1223,7 +1420,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
Input
</div>
<pre className="max-h-52 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
{inputPreview}
</pre>
</div>
@@ -1235,7 +1432,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
</div>
) : (
<div className="space-y-1">
<pre className="max-h-64 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
{resultPreview}
</pre>
</div>
@@ -108,10 +108,10 @@ export function WelcomeScreen({
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
<div className="mb-8 flex flex-col items-center">
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
What would you like to build?
What can I do for you?
</h1>
<p className="mt-2 text-balance text-center text-muted-foreground">
Start a conversation to explore, edit, and ship code together.
Let's explore, edit, and ship code together!
</p>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import type { ComponentType, ReactNode } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type PageFrameProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
};
export function PageFrame({
children,
className,
contentClassName,
}: PageFrameProps) {
return (
<ScrollArea className="h-full">
<div
className={cn(
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
}
type PageHeaderProps = {
actions?: ReactNode;
className?: string;
description?: ReactNode;
icon?: ComponentType<{ className?: string }>;
meta?: ReactNode;
title: ReactNode;
};
export function PageHeader({
actions,
className,
description,
icon: Icon,
meta,
title,
}: PageHeaderProps) {
return (
<section
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
className,
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
{title}
</h1>
{meta}
</div>
{description ? (
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
{actions}
</div>
) : null}
</section>
);
}
type PageEmptyStateProps = {
children: ReactNode;
className?: string;
};
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
return (
<div
className={cn(
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
type CommandBadgeProps = {
children: ReactNode;
className?: string;
};
export function CommandBadge({ children, className }: CommandBadgeProps) {
return (
<span
className={cn(
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
@@ -0,0 +1,550 @@
"use client";
import {
ArrowUpDown,
Check,
Filter,
Folder,
GitFork,
Loader2,
MoreHorizontal,
Pencil,
Search,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
basenamePath,
formatCostUsd,
formatRelativeTime,
parseTimestamp,
type SessionThread,
type UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import type { SessionHistoryItem } from "@/lib/session-history";
import { cn } from "@/lib/utils";
type SessionsViewProps = {
activeSessionId?: string | null;
history: UseSessionHistoryResult;
};
function statusTone(status?: string): string {
if (status === "running") return "bg-green-500";
if (status === "completed") return "bg-emerald-400";
if (status === "failed") return "bg-destructive";
if (status === "cancelled") return "bg-yellow-500";
return "bg-muted-foreground";
}
function modelLabel(thread: SessionThread): string {
if (thread.provider && thread.model) {
return `${thread.provider}:${thread.model}`;
}
return thread.model || thread.provider || "No model";
}
function tokensLabel(thread: SessionThread): string {
if (thread.inputTokens == null && thread.outputTokens == null) {
return "-";
}
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
}
function sessionFilterDetails(
thread: SessionThread,
session?: SessionHistoryItem,
): string[] {
const workspacePath = session?.workspaceRoot || session?.cwd || "";
const workspace = workspacePath ? basenamePath(workspacePath) : "";
return [
workspace ? `workspace:${workspace}` : undefined,
thread.status ? `status:${thread.status}` : undefined,
thread.provider ? `provider:${thread.provider}` : undefined,
thread.model ? `model:${thread.model}` : undefined,
].filter((detail): detail is string => Boolean(detail));
}
function sortTimestamp(session?: SessionHistoryItem) {
const timestamp = parseTimestamp(session?.endedAt || session?.startedAt);
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
const [query, setQuery] = useState("");
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
const [sortDirection, setSortDirection] = useState<"newest" | "oldest">(
"newest",
);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
null,
);
const filterOptions = useMemo(
() =>
Array.from(
new Set(
history.threads.flatMap((thread) =>
sessionFilterDetails(thread, history.sessionById.get(thread.id)),
),
),
).sort((a, b) => a.localeCompare(b)),
[history.sessionById, history.threads],
);
const filteredThreads = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const selected = new Set(sessionFilters);
const filtered = history.threads.filter((thread) => {
const session = history.sessionById.get(thread.id);
const details = sessionFilterDetails(thread, session);
const matchesFilters =
selected.size === 0 || details.some((detail) => selected.has(detail));
if (!matchesFilters) {
return false;
}
if (!normalizedQuery) {
return true;
}
const searchable = [
thread.title,
thread.codebase,
thread.provider,
thread.model,
session?.workspaceRoot,
session?.cwd,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchable.includes(normalizedQuery);
});
return [...filtered].sort((a, b) => {
const aTime = sortTimestamp(history.sessionById.get(a.id));
const bTime = sortTimestamp(history.sessionById.get(b.id));
return sortDirection === "newest" ? bTime - aTime : aTime - bTime;
});
}, [
history.sessionById,
history.threads,
query,
sessionFilters,
sortDirection,
]);
const toggleFilter = (detail: string, checked: boolean) => {
setSessionFilters((current) => {
if (checked) {
return current.includes(detail) ? current : [...current, detail];
}
return current.filter((item) => item !== detail);
});
};
const startRename = (thread: SessionThread) => {
setEditingSessionId(thread.id);
setEditingTitle(thread.title);
};
const cancelRename = () => {
setEditingSessionId(null);
setEditingTitle("");
};
const submitRename = async (thread: SessionThread) => {
const renamed = await history.renameThread(thread.id, editingTitle);
if (renamed) {
cancelRename();
}
};
const confirmDelete = async () => {
if (!deleteCandidate) {
return;
}
const deleted = await history.deleteThread(deleteCandidate.id);
if (deleted) {
setDeleteCandidate(null);
}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
<p className="mt-1 text-sm text-muted-foreground">
Recent sessions across clients and workspaces.
</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="relative min-w-44 max-w-72 flex-1">
<Search className="-translate-y-1/2 pointer-events-none absolute left-2.5 top-1/2 size-4 text-muted-foreground" />
<Input
aria-label="Search sessions"
className="h-8 pl-8"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
value={query}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Sort sessions"
className="h-8 rounded-md px-2.5"
size="sm"
title="Sort sessions"
type="button"
variant="outline"
>
<ArrowUpDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
{sortDirection === "newest" ? "Newest first" : "Newest first"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Filter sessions"
className="h-8 rounded-md px-2.5"
size="sm"
title="Filter sessions"
type="button"
variant={sessionFilters.length > 0 ? "default" : "outline"}
>
<Filter className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Filter sessions</DropdownMenuLabel>
{sessionFilters.length > 0 ? (
<>
<DropdownMenuItem onClick={() => setSessionFilters([])}>
Clear filters
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{filterOptions.length === 0 ? (
<DropdownMenuItem disabled>
No filters available
</DropdownMenuItem>
) : (
filterOptions.map((detail) => (
<DropdownMenuCheckboxItem
checked={sessionFilters.includes(detail)}
key={detail}
onCheckedChange={(checked) =>
toggleFilter(detail, checked === true)
}
>
<span className="truncate" title={detail}>
{detail}
</span>
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<span>Session</span>
<span>Workspace</span>
<span>Model</span>
<span>Tokens</span>
<span>Cost</span>
<span>Updated</span>
<span />
</div>
<div>
{history.isLoadingHistory && history.threads.length === 0 ? (
<div className="flex items-center gap-2 border-t px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading session history...
</div>
) : null}
{!history.isLoadingHistory && filteredThreads.length === 0 ? (
<div className="border-t px-4 py-8 text-sm text-muted-foreground">
{history.threads.length === 0
? "No sessions yet."
: "No sessions match the current filters."}
</div>
) : null}
{filteredThreads.map((thread) => {
const session = history.sessionById.get(thread.id);
const isEditing = editingSessionId === thread.id;
const isPending = history.pendingAction?.sessionId === thread.id;
const pendingKind = isPending
? history.pendingAction?.action
: null;
const workspace = session?.workspaceRoot || session?.cwd || "";
const updated = formatRelativeTime(
session?.endedAt || session?.startedAt,
);
return (
<div
className={cn(
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
activeSessionId === thread.id
? "bg-accent/50"
: "hover:bg-accent/30",
)}
key={thread.id}
>
{isEditing ? (
<form
className="col-span-6 grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4"
onSubmit={(event) => {
event.preventDefault();
void submitRename(thread);
}}
>
<div className="col-span-2 flex min-w-0 items-center gap-2">
<Input
aria-label={`Rename ${thread.title}`}
autoFocus
className="h-8"
disabled={pendingKind === "rename"}
onChange={(event) =>
setEditingTitle(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelRename();
}
}}
value={editingTitle}
/>
<Button
aria-label="Save title"
className="h-8 rounded-md px-2.5"
disabled={
pendingKind === "rename" || !editingTitle.trim()
}
size="sm"
type="submit"
>
{pendingKind === "rename" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
</Button>
<Button
aria-label="Cancel rename"
className="h-8 rounded-md px-2.5"
disabled={pendingKind === "rename"}
onClick={cancelRename}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" />
</Button>
</div>
<span className="truncate text-muted-foreground">
{modelLabel(thread)}
</span>
<span className="text-muted-foreground">
{tokensLabel(thread)}
</span>
<span className="text-muted-foreground">
{formatCostUsd(thread.totalCostUsd) ?? "-"}
</span>
<span className="text-muted-foreground">
{updated || thread.time}
</span>
</form>
) : (
<button
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
disabled={Boolean(pendingKind)}
onClick={() => {
if (pendingKind) {
return;
}
// Don't open the session when the user is selecting text.
if (window.getSelection()?.toString()) {
return;
}
history.openThread(thread.id);
}}
type="button"
>
<span className="flex min-w-0 items-center gap-3 font-semibold">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
statusTone(thread.status),
)}
/>
<span className="truncate">{thread.title}</span>
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Folder className="size-3.5 shrink-0" />
<span className="truncate" title={workspace}>
{workspace ? basenamePath(workspace) : "No workspace"}
</span>
</span>
<span className="truncate text-muted-foreground">
{modelLabel(thread)}
</span>
<span className="text-muted-foreground">
{tokensLabel(thread)}
</span>
<span className="text-muted-foreground">
{formatCostUsd(thread.totalCostUsd) ?? "-"}
</span>
<span className="text-muted-foreground">
{updated || thread.time}
</span>
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Session actions for ${thread.title}`}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={Boolean(pendingKind)}
type="button"
>
{pendingKind ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void history.forkThread(thread.id)}
>
<GitFork className="size-4" />
Fork
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteCandidate(thread)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
{history.mayHaveMoreSessions ? (
<div className="border-t px-4 py-3">
<Button
className="h-8 rounded-md px-3 text-xs"
disabled={history.isLoadingMore}
onClick={() =>
void history.loadMoreSessions(history.threads.length + 100)
}
type="button"
variant="outline"
>
{history.isLoadingMore ? (
<Loader2 className="size-3.5 animate-spin" />
) : null}
Load more
</Button>
</div>
) : null}
</div>
</div>
</section>
<AlertDialog
open={deleteCandidate !== null}
onOpenChange={(open) => {
if (!open && history.pendingAction?.action !== "delete") {
setDeleteCandidate(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete session?</AlertDialogTitle>
<AlertDialogDescription>
This removes "{deleteCandidate?.title ?? "this session"}" from
local history.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={history.pendingAction?.action === "delete"}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={
!deleteCandidate || history.pendingAction?.action === "delete"
}
onClick={(event) => {
event.preventDefault();
void confirmDelete();
}}
>
{history.pendingAction?.action === "delete" ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,642 @@
"use client";
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import {
CommandBadge,
PageEmptyState,
PageFrame,
PageHeader,
} from "../page-layout";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: {
flag: string;
equals?: string;
notEquals?: string;
};
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type ConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type ActiveConnector = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
};
type ConnectorChannelsResponse = {
available: ConnectorChannel[];
active: ActiveConnector[];
};
type ConnectorFormState = {
channelId: string;
values: Record<string, string>;
securityEnabled: boolean;
securityValues: Record<string, string>;
};
function connectorName(
connector: ActiveConnector,
channels: ConnectorChannel[],
): string {
return (
channels.find((channel) => channel.id === connector.type)?.name ??
connector.type
);
}
function connectorIdentity(connector: ActiveConnector): string {
if (connector.botUsername) {
return `@${connector.botUsername}`;
}
if (connector.userName) {
return connector.userName;
}
if (connector.applicationId) {
return connector.applicationId;
}
return `pid ${connector.pid}`;
}
function formatDateTime(value?: string): string {
if (!value) {
return "-";
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function isSecretField(
field: ConnectorField | ConnectorSecurityField,
): boolean {
const label = field.label.toLowerCase();
const key =
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
return (
label.includes("token") ||
label.includes("secret") ||
label.includes("key") ||
key.includes("token") ||
key.includes("secret") ||
key.includes("key")
);
}
function isMultilineField(field: ConnectorField): boolean {
const label = field.label.toLowerCase();
return label.includes("json") || field.flag.includes("credentials");
}
function shouldIncludeField(
field: ConnectorField,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) {
return true;
}
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
function initialValuesForChannel(
channel?: ConnectorChannel,
): Record<string, string> {
const values: Record<string, string> = {};
for (const field of channel?.fields ?? []) {
if (field.initialValue) {
values[field.flag] = field.initialValue;
}
}
return values;
}
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
const channel = channels[0];
return {
channelId: channel?.id ?? "",
values: initialValuesForChannel(channel),
securityEnabled: false,
securityValues: {},
};
}
export function ChannelsContent() {
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
[],
);
const [isLoading, setIsLoading] = useState(true);
const [busyChannel, setBusyChannel] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [formState, setFormState] = useState<ConnectorFormState>({
channelId: "",
values: {},
securityEnabled: false,
securityValues: {},
});
const [formError, setFormError] = useState<string | null>(null);
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
null,
);
const selectedChannel = useMemo(
() => channels.find((channel) => channel.id === formState.channelId),
[channels, formState.channelId],
);
const visibleFields = useMemo(() => {
const values = {
...initialValuesForChannel(selectedChannel),
...formState.values,
};
return (selectedChannel?.fields ?? []).filter((field) =>
shouldIncludeField(field, values),
);
}, [selectedChannel, formState.values]);
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
setChannels(response.available);
setActiveConnectors(response.active);
setFormState((prev) =>
prev.channelId ? prev : createFormState(response.available),
);
}, []);
const refreshChannels = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"list_connector_channels",
);
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, [applyResponse]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void refreshChannels();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [refreshChannels]);
const openAddDialog = () => {
setFormState(createFormState(channels));
setFormError(null);
setDialogOpen(true);
};
const updateFieldValue = (flag: string, value: string) => {
setFormState((prev) => ({
...prev,
values: { ...prev.values, [flag]: value },
}));
};
const updateSecurityFieldValue = (key: string, value: string) => {
setFormState((prev) => ({
...prev,
securityValues: { ...prev.securityValues, [key]: value },
}));
};
const startConnector = async () => {
if (!selectedChannel) {
setFormError("Choose a channel");
return;
}
for (const field of selectedChannel.fields) {
if (!visibleFields.includes(field)) {
continue;
}
if (field.required && !formState.values[field.flag]?.trim()) {
setFormError(`${field.label} is required`);
return;
}
}
if (formState.securityEnabled && selectedChannel.security) {
for (const field of selectedChannel.security.fields) {
if (!formState.securityValues[field.key]?.trim()) {
setFormError(field.requiredMessage);
return;
}
}
}
setBusyChannel(selectedChannel.id);
setFormError(null);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"start_connector_channel",
{
channel: selectedChannel.id,
values: formState.values,
security: {
enabled: formState.securityEnabled,
values: formState.securityValues,
},
},
);
applyResponse(response);
setDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setFormError(message);
} finally {
setBusyChannel(null);
}
};
const stopConnector = async (connector: ActiveConnector) => {
setBusyChannel(connector.type);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"stop_connector_channel",
{ channel: connector.type },
);
applyResponse(response);
setRemoveTarget(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyChannel(null);
}
};
return (
<PageFrame>
<PageHeader
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline.`}
title="Channels"
meta={<CommandBadge>cline connect</CommandBadge>}
actions={
<>
<Button
disabled={isLoading}
onClick={() => void refreshChannels()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw
className={cn("size-4", isLoading && "animate-spin")}
/>
</Button>
<Button
disabled={channels.length === 0}
onClick={openAddDialog}
size="sm"
type="button"
>
<Plus className="size-4" />
Add Channel
</Button>
</>
}
/>
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{isLoading ? (
<PageEmptyState>Loading channels...</PageEmptyState>
) : activeConnectors.length === 0 ? (
<PageEmptyState>No channels connected.</PageEmptyState>
) : (
<section className="overflow-hidden rounded-lg border bg-card">
<div className="grid gap-2 p-2.5">
{activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
</span>
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
</span>
{connector.connectionMode ? (
<span className="rounded-md border bg-background px-1.5 py-0.5">
{connector.connectionMode}
</span>
) : null}
</div>
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))}
</div>
</section>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add Channel</DialogTitle>
<DialogDescription>
Start a connector channel for Cline.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Channel</Label>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
setFormState({
channelId: value,
values: initialValuesForChannel(
channels.find((channel) => channel.id === value),
),
securityEnabled: false,
securityValues: {},
});
}}
value={formState.channelId}
>
<SelectTrigger>
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{visibleFields.map((field) => (
<div className="grid gap-2" key={field.flag}>
<Label>
{field.label}
{field.required ? (
<span className="text-destructive"> *</span>
) : null}
</Label>
{field.options ? (
<Select
onValueChange={(value) => {
if (value) {
updateFieldValue(field.flag, value);
}
}}
value={
formState.values[field.flag] ?? field.initialValue ?? ""
}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : isMultilineField(field) ? (
<Textarea
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
rows={5}
value={formState.values[field.flag] ?? ""}
/>
) : (
<Input
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.values[field.flag] ?? ""}
/>
)}
</div>
))}
{selectedChannel?.security ? (
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm">Restrict access</Label>
<Switch
checked={formState.securityEnabled}
onCheckedChange={(checked: boolean) =>
setFormState((prev) => ({
...prev,
securityEnabled: checked,
}))
}
/>
</div>
{formState.securityEnabled
? selectedChannel.security.fields.map((field) => (
<div className="grid gap-2" key={field.key}>
<Label>{field.label}</Label>
<Input
onChange={(event) =>
updateSecurityFieldValue(
field.key,
event.target.value,
)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.securityValues[field.key] ?? ""}
/>
</div>
))
: null}
</div>
) : null}
{formError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
) : null}
</div>
<DialogFooter>
<Button
disabled={busyChannel !== null}
onClick={() => setDialogOpen(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={busyChannel !== null || !selectedChannel}
onClick={() => void startConnector()}
type="button"
>
{busyChannel ? "Starting..." : "Add Channel"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={removeTarget !== null}
onOpenChange={(open: boolean) => {
if (!open) {
setRemoveTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Channel</AlertDialogTitle>
<AlertDialogDescription>
Confirm that you want to stop the active{" "}
{removeTarget ? connectorName(removeTarget, channels) : "channel"}{" "}
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyChannel !== null}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={busyChannel !== null || !removeTarget}
onClick={() => {
if (removeTarget) {
void stopConnector(removeTarget);
}
}}
className={buttonVariants({ variant: "destructive" })}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageFrame>
);
}
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -35,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
type McpTransportType = "stdio" | "sse" | "streamableHttp";
@@ -203,7 +203,10 @@ export function McpServersContent() {
}, [applyResponse]);
useEffect(() => {
void refreshServers();
const timeoutId = window.setTimeout(() => {
void refreshServers();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [refreshServers]);
const toggleServer = async (server: McpServer, disabled: boolean) => {
@@ -402,18 +405,24 @@ export function McpServersContent() {
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
MCP Servers
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
<PageFrame>
<PageHeader
description={
hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."
}
title="MCP Servers"
meta={
<>
<CommandBadge>cline config mcp</CommandBadge>
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
From settings file
</span>
</div>
<div className="flex items-center gap-2">
</>
}
actions={
<>
<Button
variant="outline"
size="sm"
@@ -423,151 +432,138 @@ export function McpServersContent() {
<RefreshCw
className={cn("h-4 w-4", isLoading && "animate-spin")}
/>
Refresh
</Button>
<Button size="sm" onClick={openCreateDialog}>
<Plus className="h-4 w-4" />
Add MCP Server
</Button>
</div>
</>
}
/>
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
</div>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
<p className="mb-6 text-xs text-muted-foreground">
{hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."}
</p>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">
Command:
</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers &&
Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">
Headers:
</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">Command:</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers && Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">Headers:</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<Dialog
open={editorOpen}
onOpenChange={(open) => {
@@ -852,6 +848,6 @@ export function McpServersContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
</PageFrame>
);
}
@@ -2,18 +2,21 @@
import {
ArrowLeft,
ChevronRight,
Copy,
Eye,
EyeOff,
FileIcon,
ImageIcon,
Link as LinkIcon,
Loader2,
Paperclip,
PlusCircle,
RefreshCw,
Settings2,
Search,
Star,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -82,94 +85,146 @@ function coerceFieldValue(
return trimmed;
}
function assignSettingsPath(
target: Record<string, unknown>,
path: string,
value: ProviderConfigFieldPrimitive,
) {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return;
let cursor = target;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const last = segments.at(-1);
if (last) {
cursor[last] = value;
}
}
export function toSettingsPatch(
values: Record<string, ProviderConfigFieldPrimitive>,
): Record<string, unknown> {
const settings: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
assignSettingsPath(settings, path, value);
}
return settings;
}
export function ProviderListContent({
providers,
onToggle,
onConfigure,
onAddProvider,
selectedProviderId,
variant = "page",
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
selectedProviderId?: string | null;
variant?: "page" | "panel";
}) {
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
const [providerSearch, setProviderSearch] = useState("");
const enabledProviderCount = providers.filter(
(provider) => provider.enabled,
).length;
const providerSearchQuery = providerSearch.trim().toLowerCase();
const filteredProviders = providerSearchQuery
? providers.filter((provider) =>
provider.name.toLowerCase().includes(providerSearchQuery),
)
: providers;
const isPanel = variant === "panel";
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Model Providers
</h2>
<Button
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
onClick={onAddProvider}
variant="ghost"
>
<PlusCircle className="h-4 w-4" />
Add Provider
</Button>
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
)}
>
<div
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="min-w-0">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
Model Providers
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
{providers.length} available &middot; {enabledProviderCount}{" "}
enabled
</p>
</div>
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
<Button
aria-label="Search providers"
className="size-8 rounded-md"
onClick={() => setProviderSearchOpen((open) => !open)}
size="icon-sm"
type="button"
variant={providerSearchOpen ? "default" : "secondary"}
>
<Search className="size-4" />
</Button>
<Button
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
onClick={onAddProvider}
type="button"
>
<PlusCircle className="size-4" />
Add provider
</Button>
</div>
</div>
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
{providers.map((prov) => (
{providerSearchOpen ? (
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search model providers"
autoFocus
className="h-7 border-0 bg-transparent px-0 text-sm"
onChange={(event) => setProviderSearch(event.target.value)}
placeholder="Search providers"
value={providerSearch}
/>
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
{filteredProviders.length === 0 ? (
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
No providers match "{providerSearch.trim()}".
</div>
) : null}
{filteredProviders.map((prov) => (
<div
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
className={cn(
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
selectedProviderId === prov.id && "bg-accent/45",
)}
key={prov.id}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
<button
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
{prov.name}
</p>
<p className="text-xs text-muted-foreground">
<p className="shrink-0 text-[15px] text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
</p>
</div>
<Button
aria-label={`Configure ${prov.name}`}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onConfigure(prov.id)}
variant="ghost"
>
<Settings2 className="h-4 w-4" />
</Button>
</button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
<button
aria-label={`Configure ${prov.name}`}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<ChevronRight className="size-4" />
</button>
</div>
))}
</div>
@@ -187,6 +242,7 @@ export function ProviderDetailContent({
modelsError,
onOAuthLogin,
oauthLoginPending = false,
variant = "page",
}: {
provider: Provider;
onBack: () => void;
@@ -196,18 +252,49 @@ export function ProviderDetailContent({
modelsError?: string | null;
onOAuthLogin?: () => void;
oauthLoginPending?: boolean;
variant?: "page" | "panel";
}) {
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
Record<string, ProviderConfigFieldPrimitive>
>(() => getInitialConfigValues(provider));
useEffect(() => {
setLocalConfigValues(getInitialConfigValues(provider));
}, [provider]);
const [modelSearchState, setModelSearchState] = useState<{
providerId: string;
value: string;
} | null>(null);
const [copiedModelState, setCopiedModelState] = useState<{
modelId: string;
providerId: string;
} | null>(null);
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
const configFields = provider.configFields ?? [];
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
const modelList = provider.modelList ?? [];
const modelSearch =
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
const copiedModelId =
copiedModelState?.providerId === provider.id
? copiedModelState.modelId
: null;
const modelSearchQuery = modelSearch.trim().toLowerCase();
const filteredModelList = modelSearchQuery
? modelList.filter(
(model) =>
model.name.toLowerCase().includes(modelSearchQuery) ||
model.id.toLowerCase().includes(modelSearchQuery),
)
: modelList;
const isPanel = variant === "panel";
useEffect(
() => () => {
if (copiedModelTimeoutRef.current !== undefined) {
window.clearTimeout(copiedModelTimeoutRef.current);
}
},
[],
);
const commitField = (
field: ProviderConfigField,
@@ -232,46 +319,83 @@ export function ProviderDetailContent({
onUpdate(updates);
};
const copyModelId = (modelId: string) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(modelId).then(() => {
setCopiedModelState({ modelId, providerId: provider.id });
if (copiedModelTimeoutRef.current !== undefined) {
window.clearTimeout(copiedModelTimeoutRef.current);
}
copiedModelTimeoutRef.current = window.setTimeout(
() => setCopiedModelState(null),
1600,
);
});
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
)}
>
{/* Back + title */}
<div className="mb-8 flex items-center gap-3">
<Button
aria-label="Back to providers"
aria-label={
isPanel ? "Close provider details" : "Back to providers"
}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={onBack}
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
{isPanel ? (
<X className="h-4 w-4" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<h2 className="text-lg font-semibold text-foreground">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
{provider.name}
</h2>
</h1>
</div>
{configFields.length > 0 ? (
<section className="mb-8">
<div className="flex flex-col gap-5">
<section
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
>
<div className="flex flex-col">
{configFields.map((field) => {
const value = localConfigValues[field.path];
const valueText = fieldValueToString(value);
const isSecret = field.type === "password" || field.secret;
const isShown = shownSecrets[field.path] ?? false;
return (
<div key={field.path}>
<header className="mb-2">
<h3 className="text-sm font-semibold text-foreground">
<div
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
key={field.path}
>
<header>
<h3 className="text-[17px] font-semibold text-foreground">
{field.label}
</h3>
{field.description ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
{field.description}
</p>
) : null}
</header>
{field.type === "boolean" ? (
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<div className="flex items-center justify-end">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
@@ -284,7 +408,7 @@ export function ProviderDetailContent({
</div>
) : field.type === "select" ? (
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
commitField(field, event.target.value)
}
@@ -301,12 +425,12 @@ export function ProviderDetailContent({
))}
</select>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
{field.type === "url" ? (
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
<Input
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
onBlur={() => commitField(field, valueText)}
onChange={(event) =>
setLocalConfigValues((current) => ({
@@ -392,10 +516,18 @@ export function ProviderDetailContent({
) : null}
{/* Models section */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<section
className={cn(
"overflow-hidden rounded-lg border",
isPanel ? "max-w-none" : "max-w-[46rem]",
)}
>
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
<h2 className="text-[17px] font-medium text-muted-foreground">
Models
</h2>
<div className="flex items-center gap-1">
<Search className="size-4 text-muted-foreground" />
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
@@ -414,39 +546,83 @@ export function ProviderDetailContent({
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-destructive">{modelsError}</p>
</div>
) : provider.modelList && provider.modelList.length > 0 ? (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
{provider.modelList.map((model) => (
<div
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
{/* Model name */}
<span className="flex-1 text-sm text-foreground font-mono">
<div className="flex items-center gap-1.5">
{model.name}
{/* Capability icons */}
{model.supportsAttachments && (
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
)}
{model.supportsVision && (
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
</span>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
) : modelList.length > 0 ? (
<div className="space-y-3">
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search models"
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
onChange={(event) =>
setModelSearchState({
providerId: provider.id,
value: event.target.value,
})
}
placeholder="Search models by name or ID"
spellCheck={false}
value={modelSearch}
/>
</div>
{filteredModelList.length > 0 ? (
<div className="max-h-125 overflow-y-scroll border-t">
{filteredModelList.map((model) => (
<div
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-w-0 flex-1 font-mono">
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
<span className="truncate">{model.name}</span>
{/* Capability icons */}
{model.supportsAttachments && (
<div title="File Support">
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
{model.supportsVision && (
<div title="Image Support">
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
</div>
<button
aria-label={`Copy model ID ${model.id}`}
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => copyModelId(model.id)}
title="Copy model ID"
type="button"
>
<span className="min-w-0 truncate">{model.id}</span>
<Copy className="size-3 shrink-0" />
{copiedModelId === model.id ? (
<span className="shrink-0 text-foreground">
Copied
</span>
) : null}
</button>
</div>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
))}
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
No models match "{modelSearch.trim()}".
</p>
</div>
)}
</div>
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import type { ProviderConfigFieldPrimitive } from "@/lib/provider-schema";
function assignSettingsPath(
target: Record<string, unknown>,
path: string,
value: ProviderConfigFieldPrimitive,
) {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return;
let cursor = target;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const last = segments.at(-1);
if (last) {
cursor[last] = value;
}
}
export function toSettingsPatch(
values: Record<string, ProviderConfigFieldPrimitive>,
): Record<string, unknown> {
const settings: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
assignSettingsPath(settings, path, value);
}
return settings;
}
@@ -1,9 +1,8 @@
"use client";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import { X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
import type {
Provider,
@@ -11,20 +10,25 @@ import type {
ProviderModelsResponse,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import {
type HubTheme,
readStoredHubTheme,
readSystemHubTheme,
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { primeExtensionsListsCache, RulesView } from "./extensions-view";
import { ChannelsContent } from "./channels-view";
import { CustomizationSectionView, RulesView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
import {
ProviderDetailContent,
ProviderListContent,
toSettingsPatch,
} from "./provider-list-view";
import {
primeRoutineOverviewCache,
RoutineSchedulesContent,
} from "./routine-view";
import { RoutineSchedulesContent } from "./routine-view";
import { toSettingsPatch } from "./settings-patch";
// -----------------------------------------------------------
// Settings nav categories
@@ -33,14 +37,15 @@ import {
const navCategories = [
"General",
"Providers",
"Extensions",
"MCP",
"Routine",
"Features",
"Marketplace",
"Extensions",
"Channels",
"Schedules",
"Account",
] as const;
type NavCategory = (typeof navCategories)[number];
export type SettingsSection = (typeof navCategories)[number];
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
@@ -53,9 +58,18 @@ let providerCatalogCache: {
// Component
// -----------------------------------------------------------
export function SettingsView({ onClose }: { onClose: () => void }) {
const [activeNav, setActiveNav] = useState<NavCategory>("Providers");
const [providersExpanded, setProvidersExpanded] = useState(true);
export function SettingsView({
chrome = "full",
initialSection = "General",
onClose,
onNavigateSection,
}: {
chrome?: "full" | "content";
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
const [providers, setProviders] = useState<Provider[]>(
() => providerCatalogCache?.providers ?? [],
);
@@ -125,14 +139,14 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
}, [setProvidersWithCache]);
useEffect(() => {
void loadProviderCatalog();
void primeRoutineOverviewCache().catch(() => {
// Keep settings responsive even if routine prefetch fails.
});
void primeExtensionsListsCache().catch(() => {
// Keep settings responsive even if extension prefetch fails.
});
}, [loadProviderCatalog]);
if (activeNav !== "Providers") {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderCatalog();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [activeNav, loadProviderCatalog]);
const persistProviderSettings = useCallback(
async (
@@ -237,13 +251,12 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
[setProvidersWithCache],
);
const enabledProviders = providers.filter((p) => p.enabled);
const selectedProvider = selectedProviderId
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -276,6 +289,7 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
const openProviderDetail = (id: string) => {
setActiveNav("Providers");
onNavigateSection?.("Providers");
setSelectedProviderId(id);
};
@@ -289,10 +303,14 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
if (!selected || (selected.modelList?.length ?? 0) > 0) {
return;
}
void loadProviderModels(selectedProviderId);
const timeoutId = window.setTimeout(() => {
void loadProviderModels(selectedProviderId);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderModels, providers, selectedProviderId]);
const backToProviderList = () => {
onNavigateSection?.("Providers");
setSelectedProviderId(null);
setAddingProvider(false);
};
@@ -319,10 +337,102 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
);
const openAddProvider = () => {
onNavigateSection?.("Providers");
setSelectedProviderId(null);
setAddingProvider(true);
};
const selectSection = (section: SettingsSection) => {
setActiveNav(section);
onNavigateSection?.(section);
setSelectedProviderId(null);
setAddingProvider(false);
};
const providerContent = addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading providers...</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : selectedProvider ? (
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
/>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
usesOAuth(selectedProvider)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
provider={selectedProvider}
variant="panel"
/>
</aside>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
);
const content =
activeNav === "Providers" ? (
providerContent
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Marketplace" ? (
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
) : activeNav === "Extensions" ? (
<RulesView />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
);
if (chrome === "content") {
return (
<div className="h-full overflow-hidden bg-background">{content}</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
@@ -344,144 +454,68 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
<nav className="w-56 shrink-0 border-r border-border">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-3">
{navCategories.map((cat) => {
if (cat === "Providers") {
return (
<div key={cat}>
<Button
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
activeNav === "Providers"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
onClick={() => {
setActiveNav("Providers");
setSelectedProviderId(null);
setAddingProvider(false);
setProvidersExpanded((p) => !p);
}}
variant="ghost"
>
<span>Providers</span>
{providersExpanded ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</Button>
{providersExpanded && (
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-border pl-2">
{enabledProviders.map((prov) => (
<Button
className={cn(
"justify-start",
selectedProviderId === prov.id
? "bg-accent/80 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/30",
)}
disabled={oauthSigningProviderId === prov.id}
key={prov.id}
onClick={() => openProviderDetail(prov.id)}
variant="ghost"
>
<span className="truncate">{prov.name}</span>
</Button>
))}
</div>
)}
</div>
);
}
return (
<Button
className={cn(
"justify-start",
activeNav === cat && !selectedProviderId
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
key={cat}
onClick={() => {
setActiveNav(cat);
setSelectedProviderId(null);
setAddingProvider(false);
}}
variant="ghost"
>
{cat}
</Button>
);
})}
{navCategories.map((cat) => (
<Button
className={cn(
"justify-start",
activeNav === cat
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
key={cat}
onClick={() => {
selectSection(cat);
}}
variant="ghost"
>
{cat}
</Button>
))}
</div>
</ScrollArea>
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">
{activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={
modelsLoadingByProvider[selectedProvider.id] ?? false
}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) =>
updateProvider(selectedProvider.id, updates)
}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading providers...
</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Routine" ? (
<RoutineSchedulesContent />
) : activeNav === "Extensions" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">{content}</div>
</div>
</div>
);
}
function GeneralSettingsContent() {
const [theme, setTheme] = useState<HubTheme>(() => {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
});
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
};
return (
<PageFrame>
<PageHeader
description="Manage desktop preferences for this browser and CLI environment."
title="Settings"
/>
<section className="max-w-[86rem]">
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Dark mode
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Keep the desktop interface in dark mode on this browser.
</p>
</div>
<Switch
aria-label="Dark mode"
checked={theme === "dark"}
onCheckedChange={updateTheme}
/>
</div>
</section>
</PageFrame>
);
}
@@ -28,6 +28,8 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
mode: "act",
systemPrompt: undefined,
maxIterations: undefined,
thinking: undefined,
reasoningEffort: undefined,
enableTools: true,
enableSpawn: undefined,
enableTeams: undefined,
@@ -116,10 +116,13 @@ export function normalizeRuntimeConfig(
): ChatSessionConfig {
const normalizedWorkspaceRoot = config.workspaceRoot.trim();
const normalizedCwd = (config.cwd?.trim() || normalizedWorkspaceRoot).trim();
const thinking = config.reasoningEffort ? true : config.thinking;
return {
...config,
workspaceRoot: normalizedWorkspaceRoot,
cwd: normalizedCwd || normalizedWorkspaceRoot,
thinking,
reasoningEffort: thinking === false ? undefined : config.reasoningEffort,
enableSpawn: false,
enableTeams: false,
};
@@ -101,7 +101,11 @@ export type ChatWsChunkEvent = {
event: AgentChunkEvent;
};
export type ChatTransportState = "connecting" | "reconnecting" | "connected";
export type ChatTransportState =
| "connecting"
| "reconnecting"
| "connected"
| "unavailable";
export type CoreLogChunk = {
level?: string;
@@ -34,6 +34,7 @@ import {
import { desktopClient } from "@/lib/desktop-client";
import {
buildSessionDiffState,
type SessionHookEvent,
EMPTY_DIFF_SUMMARY,
type SessionDiffSummary,
type SessionFileDiff,
@@ -241,6 +242,9 @@ export function useChatSession() {
const hydrationRequestIdRef = useRef(0);
const [chatTransportState, setChatTransportState] =
useState<ChatTransportState>(desktopClient.getTransportState());
const [chatTransportError, setChatTransportError] = useState<string | null>(
desktopClient.getTransportError(),
);
// ---- Ref syncs ----
useEffect(() => {
@@ -493,6 +497,50 @@ export function useChatSession() {
void refreshPromptsInQueue(sessionId);
}, [refreshPromptsInQueue, refreshSessionDiffSummary, sessionId]);
// Fallback for sessions with no tool events in the hook log (e.g. sessions
// recorded before tool_call/tool_result hook logging existed): rebuild the
// diff state from the tool messages themselves.
useEffect(() => {
if (!sessionId || fileDiffs.length > 0) {
return;
}
const events: SessionHookEvent[] = [];
for (const message of messages) {
if (message.sessionId !== sessionId || message.role !== "tool") {
continue;
}
let payload: {
toolName?: string;
input?: unknown;
result?: unknown;
isError?: boolean;
} | null = null;
try {
payload = JSON.parse(message.content);
} catch {
continue;
}
if (!payload?.toolName || payload.result == null || payload.isError) {
continue;
}
events.push({
hookName: "tool_result",
toolName: payload.toolName,
toolInput: payload.input,
toolOutput: payload.result,
});
}
if (events.length === 0) {
return;
}
const diffState = buildSessionDiffState(events);
if (diffState.fileDiffs.length === 0) {
return;
}
setFileDiffs(diffState.fileDiffs);
setDiffSummary(diffState.summary);
}, [sessionId, messages, fileDiffs.length]);
useEffect(() => {
const activeSessionId = sessionId;
if (!activeSessionId) {
@@ -790,7 +838,10 @@ export function useChatSession() {
useEffect(() => {
const unsubscribeTransport = desktopClient.subscribeTransportState(
setChatTransportState,
(state) => {
setChatTransportState(state);
setChatTransportError(desktopClient.getTransportError());
},
);
const unsubscribeEvents = desktopClient.subscribe(
"chat_event",
@@ -1669,6 +1720,7 @@ export function useChatSession() {
sessionId,
status,
chatTransportState,
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
config,
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,8 @@ export const ChatSessionConfigSchema = z.object({
systemPrompt: z.string().optional(),
rules: z.string().optional(),
maxIterations: z.number().int().positive().optional(),
thinking: z.boolean().optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
enableTools: z.boolean(),
enableSpawn: z.boolean().optional(),
enableTeams: z.boolean().optional(),

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