mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49373322be |
@@ -1,294 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,9 +1,6 @@
|
||||
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 }}"
|
||||
|
||||
@@ -85,10 +85,3 @@ 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
|
||||
|
||||
@@ -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 models |
|
||||
| Google | Gemini series models |
|
||||
| OpenAI | GPT series model |
|
||||
| Google | Gemini series model |
|
||||
| OpenRouter | 200+ models from any provider |
|
||||
| Vercel AI Gateway | Route to many providers through one gateway |
|
||||
| Vercel AI Gateway | Models through Vercel AI Gateway |
|
||||
| AWS Bedrock | Claude, Llama, and more |
|
||||
| Azure / GCP Vertex | All hosted models |
|
||||
| Cerebras / Groq | Fast inference models |
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
|
||||
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
|
||||
- Polished the status bar usage display and ClinePass model name
|
||||
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
|
||||
- The thinking-level picker now defaults its cursor to Medium instead of Off
|
||||
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
|
||||
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
|
||||
|
||||
## 3.0.37
|
||||
|
||||
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
|
||||
- Plan/act mode switches are now visible to the model, so it knows when you change modes mid-session
|
||||
- Fixed plan/act mode notices being dropped from prompts sent to the model
|
||||
- Fixed a race where switching modes in an empty session could trigger an unexpected restart
|
||||
|
||||
## 3.0.36
|
||||
|
||||
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
|
||||
|
||||
## 3.0.35
|
||||
|
||||
- ClinePass is now enabled for all CLI users
|
||||
- Recover missing interactive sessions when reading messages
|
||||
- Format structured commands in history export
|
||||
- Add the subscription promo code when linking to the dashboard subscription page
|
||||
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
|
||||
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
|
||||
- Advertise run commands as shell strings (from SDK v0.0.55)
|
||||
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
|
||||
|
||||
## 3.0.34
|
||||
|
||||
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
|
||||
- Improved the wording of the ClinePass onboarding step.
|
||||
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
|
||||
|
||||
## 3.0.33
|
||||
|
||||
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
|
||||
- Hide the ClinePass promo for users who already have a ClinePass subscription
|
||||
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
|
||||
|
||||
## 3.0.32
|
||||
|
||||
- Improved the ClinePass onboarding experience
|
||||
- Added an intermediate step before going to ClinePass model selection
|
||||
- Made the ClinePass subscription screen selectable
|
||||
- Promoted ClinePass in the startup notice
|
||||
- Used "ClinePass" as one word consistently and refined the provider UI copy
|
||||
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
|
||||
|
||||
## 3.0.31
|
||||
|
||||
- Show when request cost is covered by your Cline subscription
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
|
||||
| `--acp` | ACP (Agent Client Protocol) mode |
|
||||
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for truncation compaction or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.38",
|
||||
"version": "3.0.31",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -313,45 +313,6 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "run_commands",
|
||||
input: {
|
||||
commands: [{ command: "cmd", args: ["/c", "dir"] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
const code = await runHistoryExport("sess_1", outputPath, "text", io);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(io.writeErr).not.toHaveBeenCalled();
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
|
||||
});
|
||||
|
||||
it("fails when the session artifact is missing", async () => {
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
|
||||
const io = {
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { resolveCliSessionMetadata } from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import {
|
||||
parseLocalRowMetadata,
|
||||
@@ -63,7 +64,8 @@ export async function buildConnectorStartRequest(input: {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
@@ -13,29 +10,10 @@ export function MigrationNoticeContent(
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
const openSubscriptionPage = useCallback(() => {
|
||||
setStatus("Opening ClinePass in your browser...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened ClinePass in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus(
|
||||
"Could not open the browser automatically. Use the URL below.",
|
||||
);
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
@@ -44,24 +22,25 @@ export function MigrationNoticeContent(
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
ClinePass is a $9.99/month subscription plan to get access to the
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
We rebuilt the CLI from the ground up using the new Cline SDK. Learn
|
||||
more:{" "}
|
||||
<a href="https://github.com/cline/cline">
|
||||
<span fg={palette.act}>https://github.com/cline/cline</span>
|
||||
</a>
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
<text selectable>
|
||||
Running{" "}
|
||||
<span fg="#98c379" bg="#1f2937">
|
||||
{" cline "}
|
||||
</span>{" "}
|
||||
now opens the terminal UI. To open Kanban, use /quit and run{" "}
|
||||
<span fg="#98c379" bg="#1f2937">
|
||||
{" cline kanban "}
|
||||
</span>{" "}
|
||||
in your terminal
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Open ClinePass</text>
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
<text fg={palette.muted}>Press Esc to close</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClineCliMigrationNotice,
|
||||
markClineCliMigrationNoticeShown,
|
||||
resolveCliNoticeStatePath,
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider,
|
||||
} from "./notice";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -33,25 +26,8 @@ describe("migration notice", () => {
|
||||
it("returns the notice for a fresh data dir", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
|
||||
});
|
||||
|
||||
it("shows when only the old Kanban notice was marked as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
noticePath,
|
||||
`${JSON.stringify(
|
||||
{ shown: { "cline-cli-tui-default": true } },
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
|
||||
"cline-cli-cline-pass-intro",
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
|
||||
"Welcome to the new Cline CLI",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -70,7 +46,7 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -80,56 +56,18 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not show when ClinePass is already the active provider", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(
|
||||
dataDir,
|
||||
{},
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
|
||||
expect(
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(" cline-pass "),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress the active ClinePass provider when forced", () => {
|
||||
expect(
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider("cline-pass", {
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows for the active ClinePass provider when forced", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(
|
||||
dataDir,
|
||||
{ CLINE_FORCE_CLINE_PASS_NOTICE: "1" },
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows when forced even if disabled through the environment", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -140,7 +78,7 @@ describe("migration notice", () => {
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain("cline-cli-cline-pass-intro");
|
||||
expect(rawState).toContain("cline-cli-tui-default");
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
const NOTICE_ID = "cline-cli-cline-pass-intro";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
|
||||
const NOTICE_ID = "cline-cli-tui-default";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
|
||||
|
||||
export interface CliMigrationNotice {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CliMigrationNoticeOptions {
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
interface CliNoticeState {
|
||||
shown: Record<string, boolean>;
|
||||
}
|
||||
@@ -53,19 +49,6 @@ function readNoticeState(filePath: string): CliNoticeState {
|
||||
return { shown };
|
||||
}
|
||||
|
||||
function isForceNoticeEnabled(env: NodeJS.ProcessEnv): boolean {
|
||||
return env[FORCE_NOTICE_ENV]?.trim() === "1";
|
||||
}
|
||||
|
||||
export function shouldSuppressClineCliMigrationNoticeForActiveProvider(
|
||||
activeProviderId: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
activeProviderId?.trim() === "cline-pass" && !isForceNoticeEnabled(env)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCliNoticeStatePath(
|
||||
dataDir = resolveClineDataDir(),
|
||||
): string {
|
||||
@@ -75,29 +58,20 @@ export function resolveCliNoticeStatePath(
|
||||
export function getClineCliMigrationNotice(
|
||||
dataDir = resolveClineDataDir(),
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: CliMigrationNoticeOptions = {},
|
||||
): CliMigrationNotice | undefined {
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
const noticeState = readNoticeState(noticePath);
|
||||
const forceNotice = isForceNoticeEnabled(env);
|
||||
const forceNotice = env[FORCE_NOTICE_ENV]?.trim() === "1";
|
||||
const disableNotice = env[DISABLE_NOTICE_ENV]?.trim() === "1";
|
||||
if (disableNotice && !forceNotice) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(
|
||||
options.activeProviderId,
|
||||
env,
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: NOTICE_ID,
|
||||
title: "Try ClinePass",
|
||||
title: "Welcome to the new Cline CLI",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+13
-137
@@ -1,9 +1,6 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CliMigrationNotice,
|
||||
CliMigrationNoticeOptions,
|
||||
} from "./kanban-migration/notice";
|
||||
import type { CliMigrationNotice } from "./kanban-migration/notice";
|
||||
|
||||
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
|
||||
const fsActual = vi.hoisted(() => ({
|
||||
@@ -62,13 +59,9 @@ const dashboardMocks = vi.hoisted(() => ({
|
||||
runDashboardCommand: vi.fn(),
|
||||
}));
|
||||
const migrationNoticeMocks = vi.hoisted(() => ({
|
||||
getClineCliMigrationNotice: vi.fn<
|
||||
(
|
||||
dataDir?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
options?: CliMigrationNoticeOptions,
|
||||
) => CliMigrationNotice | undefined
|
||||
>(() => undefined),
|
||||
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
|
||||
() => undefined,
|
||||
),
|
||||
markClineCliMigrationNoticeShown: vi.fn(),
|
||||
}));
|
||||
const updateMocks = vi.hoisted(() => ({
|
||||
@@ -122,7 +115,6 @@ const telemetryMocks = vi.hoisted(() => ({
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
setCliFeatureFlagsAccountContext: vi.fn(),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
@@ -187,8 +179,7 @@ vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground:
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground,
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
setCliFeatureFlagsAccountContext:
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
}));
|
||||
@@ -267,7 +258,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReset();
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground.mockReset();
|
||||
kanbanMocks.launchKanban.mockReset();
|
||||
kanbanMocks.launchKanban.mockResolvedValue(0);
|
||||
dashboardMocks.runDashboardCommand.mockReset();
|
||||
@@ -640,8 +630,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("passes the migration notice marker into interactive mode", async () => {
|
||||
const notice = {
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
id: "cline-cli-tui-default",
|
||||
title: "Welcome to the new Cline CLI",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
@@ -672,37 +662,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the active ClinePass provider into the migration notice gate", async () => {
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/test-model",
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(
|
||||
migrationNoticeMocks.getClineCliMigrationNotice,
|
||||
).toHaveBeenCalledWith(undefined, process.env, {
|
||||
activeProviderId: "cline-pass",
|
||||
});
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialNotice: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start OAuth before onboarding in interactive mode", async () => {
|
||||
authMocks.isOAuthProvider.mockReturnValue(true);
|
||||
authMocks.normalizeProviderId.mockReturnValue("cline");
|
||||
@@ -983,7 +942,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
|
||||
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
@@ -1002,91 +961,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
).toHaveBeenCalledWith({ id: "acct-startup" });
|
||||
// The account identity must be seeded before flags are refreshed/used so
|
||||
// the background refresh resolves flags for the correct account.
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
|
||||
.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground.mock
|
||||
.invocationCallOrder[0],
|
||||
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
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"];
|
||||
|
||||
@@ -1301,7 +1183,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
strategy: "agentic",
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
@@ -1388,7 +1270,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables truncation compaction by default for prompt runs", async () => {
|
||||
it("enables agentic compaction by default for prompt runs", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
@@ -1403,7 +1285,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
strategy: "agentic",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
@@ -1415,13 +1297,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"basic",
|
||||
"say hello",
|
||||
];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
|
||||
+4
-18
@@ -20,6 +20,7 @@ import {
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext,
|
||||
} from "./utils/feature-flags";
|
||||
@@ -46,7 +47,6 @@ 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";
|
||||
@@ -956,26 +956,14 @@ export async function runCli(): Promise<void> {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
);
|
||||
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,
|
||||
@@ -1194,9 +1182,7 @@ export async function runCli(): Promise<void> {
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
activeProviderId: provider,
|
||||
});
|
||||
initialNotice = getClineCliMigrationNotice();
|
||||
if (initialNotice) {
|
||||
markInitialNoticeShown = () => {
|
||||
markClineCliMigrationNoticeShown();
|
||||
|
||||
@@ -126,8 +126,7 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
@@ -158,8 +157,7 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
@@ -168,27 +166,25 @@ describe("compactInteractiveMessages", () => {
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
content: `message ${index} ${longText}`,
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(64_000);
|
||||
expect(context.triggerTokens).toBeGreaterThan(1_000);
|
||||
expect(context.triggerTokens).toBeLessThan(context.maxInputTokens);
|
||||
return { messages: messages.slice(0, 2) };
|
||||
});
|
||||
config.compaction = { compact };
|
||||
|
||||
const result = await compactInteractiveMessages({
|
||||
config: createConfig(),
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
sessionId: "sess-compact",
|
||||
messages,
|
||||
});
|
||||
|
||||
const compactedMessages = result.compactionState?.messages ?? [];
|
||||
const compactedTextLength = compactedMessages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(typeof message.content === "string" ? message.content.length : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
expect(result.messages).toEqual(messages.slice(0, 2));
|
||||
});
|
||||
|
||||
it("reports compaction when core returns changed messages with the same count", async () => {
|
||||
@@ -218,9 +214,8 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toHaveLength(messages.length);
|
||||
expect(result.compactionState?.messages[0]?.content).toBe(
|
||||
expect(result.messages).toHaveLength(messages.length);
|
||||
expect(result.messages[0]?.content).toBe(
|
||||
"same count but content should be trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
type ReasoningSettings,
|
||||
type SessionCompactionState,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
@@ -54,12 +52,7 @@ export async function compactInteractiveMessages(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
compacted: boolean;
|
||||
canonicalMessages: Message[];
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
}): Promise<{ compacted: boolean; messages: Message[] }> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
@@ -77,6 +70,7 @@ export async function compactInteractiveMessages(input: {
|
||||
compaction: {
|
||||
...input.config.compaction,
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
},
|
||||
logger: input.config.logger,
|
||||
// Forward telemetry + sessionId so manual compactions emit
|
||||
@@ -88,11 +82,8 @@ export async function compactInteractiveMessages(input: {
|
||||
{ mode: "manual" },
|
||||
);
|
||||
if (!compact) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
return { compacted: false, messages: 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,
|
||||
@@ -100,7 +91,7 @@ export async function compactInteractiveMessages(input: {
|
||||
iteration: 0,
|
||||
messages: input.messages,
|
||||
apiMessages: input.messages,
|
||||
abortSignal: input.abortSignal ?? new AbortController().signal,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
model: {
|
||||
@@ -113,17 +104,8 @@ export async function compactInteractiveMessages(input: {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result?.messages) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
}
|
||||
return {
|
||||
compacted: true,
|
||||
canonicalMessages: input.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
};
|
||||
return { compacted: true, messages: result.messages };
|
||||
}
|
||||
|
||||
@@ -2,15 +2,7 @@ import { createTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import {
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
type AppliedModeChange,
|
||||
applyInteractiveModeConfig,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./mode";
|
||||
import { applyInteractiveModeConfig } from "./mode";
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
|
||||
@@ -48,190 +40,6 @@ 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();
|
||||
|
||||
@@ -2,42 +2,17 @@ import { createTool } from "@cline/shared";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
|
||||
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.";
|
||||
type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
export function createInteractiveModeSwitchTool(input: {
|
||||
config: Config;
|
||||
pendingModeChange: PendingModeChange;
|
||||
pendingModeChange: { current: InteractiveUiMode | null };
|
||||
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
|
||||
}) {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"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.",
|
||||
"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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -45,101 +20,17 @@ 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") {
|
||||
// Throw instead of returning: a successful result would end the
|
||||
// run via completesRun even though nothing changed.
|
||||
throw new Error("Already in act mode.");
|
||||
return "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,89 +1,81 @@
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
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 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: createCliCoreMock,
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
markAbortInProgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
};
|
||||
}
|
||||
|
||||
function createChatCommandState(config = createConfig()): ChatCommandState {
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
@@ -92,35 +84,6 @@ function createChatCommandState(config = createConfig()): 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",
|
||||
@@ -137,9 +100,9 @@ function makeManager() {
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -151,8 +114,6 @@ 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(),
|
||||
@@ -172,7 +133,7 @@ function makeTurnResult() {
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
@@ -189,22 +150,20 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function makeRuntime(
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
config?: Config;
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const config = options.config ?? createConfig();
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: createChatCommandState(config),
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
@@ -213,325 +172,26 @@ async function makeRuntime(
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
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);
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
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(),
|
||||
});
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
@@ -542,7 +202,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
@@ -550,54 +210,18 @@ 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" },
|
||||
}));
|
||||
createRuntimeHooksMock.mockReturnValueOnce({
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = await makeRuntime(manager, {
|
||||
const runtime = makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
@@ -649,51 +273,14 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
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(),
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
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(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
@@ -701,14 +288,16 @@ 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(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
@@ -725,46 +314,8 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
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(),
|
||||
});
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
@@ -786,7 +337,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = await makeRuntime(manager);
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
@@ -814,48 +365,6 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
new SessionNotFoundError("session-1"),
|
||||
);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: [],
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
|
||||
const manager = makeManager();
|
||||
let runtime!: Awaited<ReturnType<typeof makeRuntime>>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "stale" }],
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
@@ -865,7 +374,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = await makeRuntime(manager);
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
|
||||
@@ -2,13 +2,10 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -52,13 +49,6 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type CurrentMessagesRead =
|
||||
| { messages: Message[]; status: "read" }
|
||||
| { messages: Message[]; status: "recovered" }
|
||||
| { messages: Message[]; status: "stale" };
|
||||
type MissingSessionRecovery = {
|
||||
messages: Message[];
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
) => NonNullable<Config["toolPolicies"]>[string];
|
||||
@@ -113,13 +103,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
let missingSessionRecoveryPromise:
|
||||
| Promise<MissingSessionRecovery>
|
||||
| undefined;
|
||||
let missingSessionRecoveryPromise: Promise<void> | undefined;
|
||||
// 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;
|
||||
|
||||
@@ -209,7 +196,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
@@ -219,7 +205,6 @@ export function createInteractiveSessionRuntime(input: {
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -290,53 +275,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
return { messages: [], status: "read" };
|
||||
}
|
||||
try {
|
||||
const messages = (await manager.readMessages(sessionId)) ?? [];
|
||||
return {
|
||||
messages,
|
||||
status: activeSessionId === sessionId ? "read" : "stale",
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
abortRequested ||
|
||||
shutdownRequested ||
|
||||
!isSessionNotFoundError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const recovery = await recoverMissingActiveSession(error);
|
||||
return { messages: recovery.messages, status: "recovered" };
|
||||
const readCurrentMessages = async (): Promise<Message[]> => {
|
||||
if (!sessionManager || !activeSessionId) {
|
||||
return [];
|
||||
}
|
||||
return (await sessionManager.readMessages(activeSessionId)) ?? [];
|
||||
};
|
||||
|
||||
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> => {
|
||||
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
|
||||
if (missingSessionRecoveryPromise) {
|
||||
return await missingSessionRecoveryPromise;
|
||||
}
|
||||
@@ -344,7 +290,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const manager = sessionManager;
|
||||
const missingSessionId = activeSessionId;
|
||||
if (!manager || !missingSessionId || shutdownRequested) {
|
||||
return { messages: [] };
|
||||
return;
|
||||
}
|
||||
const messages = await manager
|
||||
.readMessages(missingSessionId)
|
||||
@@ -361,22 +307,12 @@ export function createInteractiveSessionRuntime(input: {
|
||||
startupError = undefined;
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages);
|
||||
return { messages };
|
||||
})().finally(() => {
|
||||
missingSessionRecoveryPromise = undefined;
|
||||
});
|
||||
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) {
|
||||
@@ -411,69 +347,22 @@ export function createInteractiveSessionRuntime(input: {
|
||||
});
|
||||
};
|
||||
|
||||
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 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 restartWithCurrentMessages = async (): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await restartWithMessages(
|
||||
messages,
|
||||
undefined,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
const messages = await readCurrentMessages();
|
||||
await restartWithMessages(messages);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
@@ -587,10 +476,6 @@ 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,
|
||||
@@ -598,17 +483,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
sourceSession: sessionRecord,
|
||||
messages,
|
||||
});
|
||||
await startFreshSession(
|
||||
messages,
|
||||
forkMetadata,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
};
|
||||
|
||||
@@ -630,52 +505,22 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
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) {
|
||||
if (!sessionManager) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status === "stale" || (status === "recovered" && !activeSessionId)) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
// If reading messages recovered the session, `messages` are the same messages
|
||||
// used to seed the replacement session, so it is safe to compact the current
|
||||
// active session with them.
|
||||
const messages = await readCurrentMessages();
|
||||
const messagesBefore = messages.length;
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
const result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: activeSessionId,
|
||||
messages,
|
||||
});
|
||||
if (!result.compacted) {
|
||||
return {
|
||||
messagesBefore,
|
||||
@@ -683,24 +528,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
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.");
|
||||
}
|
||||
await restartWithMessages(result.messages);
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: result.canonicalMessages.length,
|
||||
workingContextMessagesAfter: result.compactionState?.messages.length,
|
||||
messagesAfter: result.messages.length,
|
||||
compacted: true,
|
||||
};
|
||||
};
|
||||
@@ -720,10 +551,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return undefined;
|
||||
}
|
||||
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status !== "read") {
|
||||
return undefined;
|
||||
}
|
||||
const messages = await readCurrentMessages();
|
||||
return { messages, checkpointHistory };
|
||||
};
|
||||
|
||||
@@ -790,9 +618,6 @@ 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(() => {});
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
@@ -24,7 +20,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
|
||||
|
||||
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.`;
|
||||
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.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
@@ -35,13 +31,10 @@ 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}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
rules = rules
|
||||
? `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`
|
||||
: PLAN_MODE_INSTRUCTIONS;
|
||||
}
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -53,13 +52,7 @@ import {
|
||||
type InteractiveExitSummary,
|
||||
} from "./interactive/exit-summary";
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./interactive/mode";
|
||||
import { createInteractiveModeSwitchTool } from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
@@ -156,9 +149,8 @@ export async function runInteractive(
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
const pendingModeChange: { current: "plan" | "act" | null } = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
@@ -212,7 +204,6 @@ 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";
|
||||
@@ -227,11 +218,7 @@ 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;
|
||||
@@ -402,7 +389,7 @@ export async function runInteractive(
|
||||
? async () => {
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
const { messages } = await sessionRuntime.readCurrentMessages();
|
||||
const messages = await sessionRuntime.readCurrentMessages();
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -533,50 +520,27 @@ 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 (): Promise<
|
||||
AppliedModeChange | undefined
|
||||
> => {
|
||||
const applyPendingModeChange = async () => {
|
||||
if (!pendingModeChange.current) return undefined;
|
||||
const applied: AppliedModeChange = {
|
||||
mode: pendingModeChange.current,
|
||||
source: pendingModeChange.source ?? "ui",
|
||||
};
|
||||
const newMode = pendingModeChange.current;
|
||||
pendingModeChange.current = null;
|
||||
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;
|
||||
await sessionRuntime.applyMode(newMode);
|
||||
tuiModeChanged.current?.(newMode);
|
||||
return newMode;
|
||||
};
|
||||
|
||||
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,
|
||||
const result = await sessionRuntime.sendCurrentTurn({
|
||||
prompt: userInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
});
|
||||
|
||||
await applyPendingModeChange();
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
@@ -678,7 +642,6 @@ export async function runInteractive(
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
sessionRuntime.abortAll();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
normalizeUserInput,
|
||||
type ToolResultContent,
|
||||
type ToolUseContent,
|
||||
} from "@cline/shared";
|
||||
import { formatStructuredCommand } from "../utils/helpers";
|
||||
|
||||
export interface ConversationHistory {
|
||||
version: number;
|
||||
@@ -681,7 +680,7 @@ function renderContentHTML(
|
||||
toolResultsMap: Map<string, ToolResultContent>,
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
const text = isUser ? formatDisplayUserInput(content) : content;
|
||||
const text = isUser ? normalizeUserInput(content) : content;
|
||||
return renderTextHTML(text);
|
||||
}
|
||||
|
||||
@@ -689,7 +688,7 @@ function renderContentHTML(
|
||||
.map((block) => {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
const text = isUser ? formatDisplayUserInput(block.text) : block.text;
|
||||
const text = isUser ? normalizeUserInput(block.text) : block.text;
|
||||
return renderTextHTML(text);
|
||||
}
|
||||
case "tool_use":
|
||||
@@ -846,15 +845,15 @@ function renderDiffHTML(
|
||||
}
|
||||
|
||||
function renderCommandsHTML(
|
||||
commands: unknown[],
|
||||
commands: string[],
|
||||
_result?: ToolResultContent,
|
||||
): string {
|
||||
return commands
|
||||
.map(
|
||||
(command, i) => `
|
||||
(cmd, i) => `
|
||||
<div class="command-block">
|
||||
<div class="command-label">Command ${i + 1}</div>
|
||||
<code>${escapeHtml(formatStructuredCommand(command))}</code>
|
||||
<code>${escapeHtml(cmd)}</code>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -124,7 +124,7 @@ export function clineEnv(
|
||||
}),
|
||||
CLINE_SESSION_DATA_DIR: path.join(dataDir, "sessions"),
|
||||
CLINE_TEAM_DATA_DIR: path.join(dataDir, "teams"),
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
NO_UPDATE_NOTIFIER: "1",
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
...extra,
|
||||
|
||||
@@ -13,7 +13,6 @@ const coreMocks = vi.hoisted(() => {
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
fetchCurrentUserPlan: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
@@ -46,9 +45,6 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
fetchCurrentUserPlan() {
|
||||
return coreMocks.fetchCurrentUserPlan();
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -111,7 +107,6 @@ describe("createClineAccountService", () => {
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -209,7 +204,6 @@ describe("loadClineAccountSnapshot", () => {
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -274,7 +268,6 @@ describe("loadIndividualSubscriptionPlans", () => {
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
@@ -126,10 +125,8 @@ export async function createClineAccountService(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager =
|
||||
input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const manager = new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
@@ -219,48 +216,6 @@ export async function loadIndividualSubscriptionPlans(input: {
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlan(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlanFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
|
||||
@@ -6,10 +6,10 @@ import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getUserMessageBackground,
|
||||
getModeInputBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { getSyntaxStyle } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
parseApplyPatchInput,
|
||||
@@ -38,6 +37,12 @@ import {
|
||||
} from "../utils/tool-parsing";
|
||||
import { ToolOutput } from "./tool-output";
|
||||
|
||||
function getIndividualPlanFeatures(plans: ClineSubscriptionPlan[]): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function trimLeading(text: string): string {
|
||||
return text.replace(/^\n+/, "");
|
||||
}
|
||||
@@ -269,8 +274,14 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE =
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue.";
|
||||
const OUT_OF_CREDITS_MESSAGE =
|
||||
"You have run out of Cline credits. Add credits in the dashboard to continue.";
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -286,46 +297,30 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
|
||||
isClinePassEnabled
|
||||
? CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE
|
||||
: OUT_OF_CREDITS_MESSAGE
|
||||
}
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="gray">Dashboard: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase ClinePass: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Switch to ClinePass: </text>
|
||||
<text fg="gray">
|
||||
type /settings in CLI and switch provider to ClinePass
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadIndividualSubscriptionPlans) {
|
||||
@@ -350,15 +345,15 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={planAccent} content="* " />
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={planAccent}
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg={planAccent}>ClinePass subscription required</text>
|
||||
<text fg="yellow">ClinePass subscription required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -368,22 +363,24 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={props.defaultFg}>ClinePass includes:</text>
|
||||
{planFeatures.map((feature) => (
|
||||
<text key={feature} fg={props.defaultFg} selectable>
|
||||
<span fg="green">✓ </span>
|
||||
<span>{feature}</span>
|
||||
</text>
|
||||
<box key={feature} flexDirection="row">
|
||||
<text fg="green" content="✓ " />
|
||||
<text fg={props.defaultFg} selectable>
|
||||
{feature}
|
||||
</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -394,21 +391,18 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={planAccent} content="* " />
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={planAccent}
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg={planAccent}>Personal ClinePass required</text>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -422,15 +416,16 @@ 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, mode = "act", terminalTheme } = props;
|
||||
const { entry, accent = palette.act, terminalTheme } = props;
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const userMsgBg = getUserMessageBackground(terminalBg);
|
||||
const userMsgBg = getModeInputBackground(
|
||||
accent === palette.plan ? "plan" : "act",
|
||||
terminalBg,
|
||||
);
|
||||
|
||||
switch (entry.kind) {
|
||||
case "user":
|
||||
@@ -441,9 +436,10 @@ 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}
|
||||
@@ -459,9 +455,10 @@ 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>}
|
||||
@@ -486,7 +483,7 @@ export function ChatEntryView(props: {
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme)}
|
||||
streaming={entry.streaming}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
@@ -519,7 +516,6 @@ export function ChatEntryView(props: {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -530,7 +526,6 @@ export function ChatEntryView(props: {
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -562,7 +557,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(2)}`);
|
||||
if (entry.cost > 0) parts.push(`$${entry.cost.toFixed(3)}`);
|
||||
if (entry.iterations > 0)
|
||||
parts.push(
|
||||
`${entry.iterations} iteration${entry.iterations !== 1 ? "s" : ""}`,
|
||||
|
||||
@@ -96,15 +96,11 @@ 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={getModeAccent(entryMode, terminalTheme)}
|
||||
mode={entryMode === "plan" ? "plan" : "act"}
|
||||
accent={accent}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ export function AccountDialogContent(
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">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={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">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={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">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={palette.act}>Change Account</text>
|
||||
<text fg="cyan">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={palette.act}>Cline Account</text>
|
||||
<text fg="cyan">Cline Account</text>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
@@ -514,7 +514,7 @@ export function AccountDialogContent(
|
||||
border
|
||||
borderColor="gray"
|
||||
>
|
||||
<text fg={palette.act}>{userInitial(loaded)}</text>
|
||||
<text fg="cyan">{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 : palette.act}
|
||||
fg={isSelected ? palette.textOnSelection : "cyan"}
|
||||
width={shortcutWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ExtDetailContent(
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{row.name}</strong>
|
||||
</text>
|
||||
<text
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @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 }
|
||||
@@ -278,7 +277,7 @@ export function HelpDialogContent(props: ChoiceContext<void>) {
|
||||
}
|
||||
return (
|
||||
<box key={row.id} flexDirection="row" paddingX={1}>
|
||||
<text fg={palette.act} width={KEY_WIDTH} flexShrink={0}>
|
||||
<text fg="cyan" 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={palette.act}>MCP Servers</text>
|
||||
<text fg="cyan">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 ? palette.act : "gray";
|
||||
let rowColor = isSel ? "cyan" : "gray";
|
||||
if (enabled && typeof srv.enabled === "boolean") {
|
||||
rowColor = palette.success;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
const url = new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -252,8 +251,7 @@ export function ProviderPickerContent(
|
||||
export type ExistingProviderAction =
|
||||
| "use_existing"
|
||||
| "reconfigure"
|
||||
| "open_subscription_page"
|
||||
| "open_usage_billing";
|
||||
| "open_subscription";
|
||||
|
||||
export interface ExistingProviderOption {
|
||||
value: ExistingProviderAction;
|
||||
@@ -261,6 +259,18 @@ export interface ExistingProviderOption {
|
||||
onSelect?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
return new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function UseExistingOrReconfigureContent(
|
||||
props: ChoiceContext<ExistingProviderOption> & {
|
||||
providerName: string;
|
||||
@@ -330,34 +340,28 @@ export function UseExistingOrReconfigureContent(
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassBrowserPageContent(
|
||||
export function ClinePassSubscriptionContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerName: string;
|
||||
pageLabel: string;
|
||||
url: string;
|
||||
openedStatus: string;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerName,
|
||||
pageLabel,
|
||||
url,
|
||||
openedStatus,
|
||||
} = props;
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const subscriptionUrl = useMemo(
|
||||
() =>
|
||||
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
|
||||
[],
|
||||
);
|
||||
const [status, setStatus] = useState("Opening browser...");
|
||||
|
||||
useEffect(() => {
|
||||
void open(url, { wait: false })
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus(openedStatus);
|
||||
setStatus("Opened subscription page in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
});
|
||||
}, [url, openedStatus]);
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -371,15 +375,15 @@ function ClinePassBrowserPageContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text>{status}</text>
|
||||
|
||||
<text fg="gray">{pageLabel}:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={url}>{url}</a>
|
||||
<text fg="gray">Subscription page:</text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
@@ -389,27 +393,6 @@ function ClinePassBrowserPageContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinePassSubscriptionContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const subscriptionUrl = useMemo(
|
||||
() =>
|
||||
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClinePassBrowserPageContent
|
||||
{...props}
|
||||
pageLabel="Subscription page"
|
||||
url={subscriptionUrl}
|
||||
openedStatus="Opened subscription page in your browser."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
@@ -596,7 +579,7 @@ export function ProviderConfigInputContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -689,7 +672,7 @@ export function CodexCliStatusContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -707,7 +690,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={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
@@ -869,7 +852,7 @@ export function OAuthLoginContent(
|
||||
if (mode === "device") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
@@ -884,7 +867,7 @@ export function OAuthLoginContent(
|
||||
<strong>{deviceUserCode}</strong>
|
||||
</text>
|
||||
<text fg="gray">Visit this URL and enter the code above:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={deviceVerifyUrl}>{deviceVerifyUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -901,7 +884,7 @@ export function OAuthLoginContent(
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<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 : palette.act}>
|
||||
<text fg={isSelected ? palette.textOnSelection : "cyan"}>
|
||||
{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={palette.act} marginTop={1}>
|
||||
<text fg="cyan" marginTop={1}>
|
||||
<strong>{props.request.toolName}</strong>
|
||||
</text>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export type TextareaHandle = Pick<
|
||||
|
||||
export interface InputBarProps {
|
||||
accent: string;
|
||||
ruleColor: string;
|
||||
inputBackground: string;
|
||||
inputForeground: string;
|
||||
inputPlaceholder: string;
|
||||
placeholder: string;
|
||||
@@ -62,7 +62,7 @@ function readTextPaste(event: PasteEvent): string | null {
|
||||
export function InputBar(props: InputBarProps) {
|
||||
const {
|
||||
accent,
|
||||
ruleColor,
|
||||
inputBackground,
|
||||
inputForeground,
|
||||
inputPlaceholder,
|
||||
placeholder,
|
||||
@@ -197,13 +197,13 @@ export function InputBar(props: InputBarProps) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
border={["top", "bottom"]}
|
||||
borderStyle="single"
|
||||
borderColor={ruleColor}
|
||||
backgroundColor={inputBackground}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
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 palette.act;
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
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 palette.act;
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
@@ -272,7 +272,7 @@ export function ClineModelSelectorDialogContent(
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.act}>Choose a model</text>
|
||||
<text fg="cyan">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={palette.act}>Choose a model</text>
|
||||
<text fg="cyan">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,8 +329,7 @@ export function ThinkingLevelContent(
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === currentLevel);
|
||||
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 : palette.act} flexShrink={0}>
|
||||
<text fg={focused ? palette.selection : "cyan"} flexShrink={0}>
|
||||
Provider:
|
||||
</text>
|
||||
<text fg="white">{providerName}</text>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
createContextBar,
|
||||
formatStatusBarUsageText,
|
||||
resolveContextBarFilledForeground,
|
||||
resolveModelDisplayName,
|
||||
} from "./status-bar";
|
||||
|
||||
vi.mock("@opentui/react", () => ({
|
||||
@@ -14,14 +13,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",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(50, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(100, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
@@ -29,17 +28,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",
|
||||
empty: "\u2588\u2588\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",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588",
|
||||
});
|
||||
expect(createContextBar(1_000_000, 1_000_000)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
@@ -58,75 +57,16 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345) $0.12");
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("displays subscription message when the provider is a subscription provider", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
).toBe("(12,345)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelDisplayName", () => {
|
||||
it("uses the friendly model name with a ClinePass prefix", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "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", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
}),
|
||||
).toBe("GLM 5.2");
|
||||
).toBe("(12,345 tokens) $0.00 (included with your subscription)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { HOME_VIEW_MAX_WIDTH } from "../types";
|
||||
export function createContextBar(
|
||||
used: number,
|
||||
total?: number,
|
||||
width = 6,
|
||||
width = 8,
|
||||
): { 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 "";
|
||||
return "$0.00 (included with your subscription)";
|
||||
}
|
||||
|
||||
if (!shouldShowCliUsageCost(providerId)) {
|
||||
@@ -66,7 +66,7 @@ export function formatStatusBarUsageText(input: {
|
||||
totalCost: number;
|
||||
providerId: string;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
const costText = formatCostText(input.providerId, input.totalCost);
|
||||
|
||||
if (!costText) {
|
||||
@@ -94,22 +94,17 @@ function lookupModelInfo(
|
||||
}
|
||||
|
||||
export function resolveModelDisplayName(config: {
|
||||
providerId?: string;
|
||||
modelId: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: string;
|
||||
}): string {
|
||||
const info = lookupModelInfo(config.modelId, config.knownModels);
|
||||
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
|
||||
let displayName = info?.name ?? modelIdTail;
|
||||
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
|
||||
if (config.thinking && config.reasoningEffort) {
|
||||
displayName = `${displayName} (${config.reasoningEffort})`;
|
||||
return `${name} (${config.reasoningEffort})`;
|
||||
}
|
||||
if (config.providerId === "cline-pass") {
|
||||
displayName = `ClinePass: ${displayName}`;
|
||||
}
|
||||
return displayName;
|
||||
return name;
|
||||
}
|
||||
|
||||
export function resolveModelMaxInputTokens(config: {
|
||||
|
||||
@@ -103,16 +103,9 @@ 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);
|
||||
@@ -139,9 +132,8 @@ export function SessionProvider(props: {
|
||||
);
|
||||
|
||||
const appendEntry = useCallback((entry: ChatEntry) => {
|
||||
const stamped = entry.mode ? entry : { ...entry, mode: uiModeRef.current };
|
||||
setEntries((prev) => {
|
||||
const next = [...prev, stamped];
|
||||
const next = [...prev, entry];
|
||||
return next.length <= MAX_BUFFERED_LINES
|
||||
? next
|
||||
: next.slice(next.length - MAX_BUFFERED_LINES);
|
||||
@@ -196,8 +188,8 @@ export function SessionProvider(props: {
|
||||
}, []);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setUiMode(uiModeRef.current === "act" ? "plan" : "act");
|
||||
}, [setUiMode]);
|
||||
setUiMode((m) => (m === "act" ? "plan" : "act"));
|
||||
}, []);
|
||||
|
||||
const toggleAutoApprove = useCallback(() => {
|
||||
const next = !autoApproveAllRef.current;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
@@ -297,13 +296,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const handlePendingPromptSubmitted = useCallback(
|
||||
(event: PendingPromptSubmittedEvent) => {
|
||||
knownPendingPromptIdsRef.current.delete(event.id);
|
||||
// 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({ kind: "user_submitted", text: event.prompt });
|
||||
},
|
||||
[appendEntry],
|
||||
);
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("formatCompactionStatus", () => {
|
||||
messagesAfter: 300,
|
||||
compacted: true,
|
||||
}),
|
||||
).toBe("Compacted context; message count stayed at 300 messages.");
|
||||
).toBe("Compacted context; message count stayed at 300.");
|
||||
});
|
||||
|
||||
it("reports empty sessions separately", () => {
|
||||
|
||||
@@ -75,10 +75,9 @@ export function useLocalCommandActions(input: {
|
||||
});
|
||||
} else {
|
||||
session.clearEntries();
|
||||
// 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);
|
||||
for (const entry of entries) {
|
||||
session.appendEntry(entry);
|
||||
}
|
||||
if (typeof result.currentContextSize === "number") {
|
||||
session.setLastTotalTokens(result.currentContextSize);
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ function providerToExistingProviderOptions(input: {
|
||||
|
||||
return [
|
||||
{
|
||||
value: "open_subscription_page",
|
||||
label: "Manage subscription & see usage",
|
||||
value: "open_subscription",
|
||||
label: "Open ClinePass subscription page",
|
||||
onSelect: async () => {
|
||||
await input.dialog.choice<boolean>({
|
||||
style: { maxHeight: input.termHeight - 2 },
|
||||
|
||||
@@ -23,15 +23,15 @@ describe("getTerminalTheme", () => {
|
||||
});
|
||||
|
||||
describe("theme-aware palette helpers", () => {
|
||||
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("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 darker accents on light terminals", () => {
|
||||
expect(getModeAccent("act", "light")).toBe("#0f72cb");
|
||||
expect(getModeAccent("plan", "light")).toBe("#867100");
|
||||
expect(getModeAccent("act", "light")).toBe("#0969da");
|
||||
expect(getModeAccent("plan", "light")).toBe("#9a6700");
|
||||
expect(getSuccessColor("light")).toBe("#116329");
|
||||
});
|
||||
});
|
||||
|
||||
+19
-52
@@ -1,9 +1,9 @@
|
||||
export const palette = {
|
||||
act: "#79b8ff",
|
||||
plan: "#ffea7f",
|
||||
selection: "#79b8ff",
|
||||
act: "cyan",
|
||||
plan: "yellow",
|
||||
selection: "cyan",
|
||||
error: "red",
|
||||
success: "#99e89b",
|
||||
success: "brightGreen",
|
||||
muted: "gray",
|
||||
textOnSelection: "black",
|
||||
} as const;
|
||||
@@ -16,11 +16,9 @@ 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: "#0f72cb",
|
||||
plan: "#867100",
|
||||
act: "#0969da",
|
||||
plan: "#9a6700",
|
||||
success: "#116329",
|
||||
},
|
||||
} as const;
|
||||
@@ -31,7 +29,7 @@ export const diffPalettes = {
|
||||
removedBg: "#4d1a1a",
|
||||
addedLineNumberBg: "#1a4d1a",
|
||||
removedLineNumberBg: "#4d1a1a",
|
||||
addedSignColor: "#99e89b",
|
||||
addedSignColor: "#22c55e",
|
||||
removedSignColor: "#ef4444",
|
||||
lineNumberFg: "#888888",
|
||||
},
|
||||
@@ -77,8 +75,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/blue):
|
||||
// -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/cyan):
|
||||
// -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):
|
||||
@@ -133,53 +131,22 @@ export function getDefaultForeground(
|
||||
return isLightTheme(terminalBg) ? "#1a1a1a" : undefined;
|
||||
}
|
||||
|
||||
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 = baseLift / (1 + (light ? 1 - base.L : base.L) * LIFT_DAMPING);
|
||||
return oklabToHex(
|
||||
base.L + (light ? -lift : lift),
|
||||
base.a + nudgeA,
|
||||
base.b + nudgeB,
|
||||
);
|
||||
}
|
||||
|
||||
export function getModeInputBackground(
|
||||
mode: string,
|
||||
terminalBg: string | null,
|
||||
): 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";
|
||||
return liftedFromTerminalBg(
|
||||
terminalBg,
|
||||
BASE_LIFT,
|
||||
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
|
||||
warm ? CHROMA_NUDGE : -CHROMA_NUDGE,
|
||||
return oklabToHex(
|
||||
base.L + (light ? -lift : lift),
|
||||
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + 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,
|
||||
@@ -190,7 +157,7 @@ export function getModeInputForeground(
|
||||
return oklabToHex(
|
||||
base.L,
|
||||
base.a + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + (warm ? CHROMA_NUDGE : -CHROMA_NUDGE),
|
||||
base.b + CHROMA_NUDGE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,7 +171,7 @@ export function getModeInputPlaceholder(
|
||||
return oklabToHex(
|
||||
base.L,
|
||||
base.a + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
|
||||
base.b + (warm ? CHROMA_NUDGE * 2 : -CHROMA_NUDGE * 2),
|
||||
base.b + CHROMA_NUDGE * 2,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
useDialogState,
|
||||
} from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
|
||||
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
@@ -401,10 +400,9 @@ function App(props: TuiProps) {
|
||||
if (lastEntry && lastEntry.kind === "user_submitted") {
|
||||
entries.pop();
|
||||
}
|
||||
// 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);
|
||||
for (const entry of entries) {
|
||||
session.appendEntry(entry);
|
||||
}
|
||||
session.setHasSubmitted(entries.length > 0);
|
||||
setAppView(entries.length > 0 ? "chat" : "home");
|
||||
populateInputRef.current(picked.fullText);
|
||||
@@ -543,17 +541,10 @@ function App(props: TuiProps) {
|
||||
|
||||
const notice = props.initialNotice;
|
||||
const onInitialNoticeShown = props.onInitialNoticeShown;
|
||||
const currentProviderId = props.config.providerId;
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
if (initialNoticeShownRef.current) return;
|
||||
if (appView !== "home") return;
|
||||
if (
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
|
||||
) {
|
||||
initialNoticeShownRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
initialNoticeShownRef.current = true;
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -569,7 +560,7 @@ function App(props: TuiProps) {
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
|
||||
}, [appView, dialog, notice, onInitialNoticeShown]);
|
||||
|
||||
const {
|
||||
appendEntry: appendSessionEntry,
|
||||
|
||||
@@ -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,17 +52,7 @@ 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: {
|
||||
@@ -90,7 +80,6 @@ export interface ResumedSessionResult {
|
||||
export interface InteractiveCompactionResult {
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
@@ -13,11 +9,8 @@ export function formatCompactionStatus(
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
parseUserInputMode,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatDisplayUserInput, type Message } from "@cline/shared";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
@@ -17,10 +11,13 @@ 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 shouldHydrateMessage(msg: PersistedMessage): boolean {
|
||||
const displayRole = getDisplayRole(msg);
|
||||
return (
|
||||
displayRole !== "system" &&
|
||||
displayRole !== "status" &&
|
||||
msg.metadata?.kind !== "compaction_summary"
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyToolResult(
|
||||
@@ -42,32 +39,21 @@ 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);
|
||||
if (displayRole === "system" || displayRole === "status") {
|
||||
if (!shouldHydrateMessage(msg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.role === "user") {
|
||||
mode = parseUserInputMode(msg.content) ?? mode;
|
||||
const text = formatDisplayUserInput(msg.content);
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text, mode });
|
||||
}
|
||||
if (text) entries.push({ kind: "user_submitted", text });
|
||||
} else {
|
||||
entries.push({
|
||||
kind: "assistant_text",
|
||||
text: msg.content,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -84,7 +70,6 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
kind: "assistant_text",
|
||||
text: block.text,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -95,7 +80,6 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
kind: "reasoning",
|
||||
text: block.thinking,
|
||||
streaming: false,
|
||||
mode,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -111,14 +95,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -144,10 +122,9 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
|
||||
if (msg.role === "user" && userTextParts.length > 0) {
|
||||
const combined = userTextParts.join("\n");
|
||||
mode = parseUserInputMode(combined) ?? mode;
|
||||
const text = formatDisplayUserInput(combined);
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text, mode });
|
||||
if (text) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,33 +49,4 @@ 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
|
||||
import { type TerminalTheme, themePalette } from "../palette";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
|
||||
// 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>();
|
||||
const instances: Record<TerminalTheme, SyntaxStyle | null> = {
|
||||
dark: null,
|
||||
light: null,
|
||||
};
|
||||
|
||||
interface SyntaxColors {
|
||||
keyword: string;
|
||||
@@ -24,34 +22,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: "#d7a0e3",
|
||||
operator: "#9bbbdd",
|
||||
type: "#dfca7d",
|
||||
functionName: themePalette.dark.act,
|
||||
variable: "#ee939b",
|
||||
string: "#99e89b",
|
||||
number: "#f0ad7f",
|
||||
keyword: "#c678dd",
|
||||
operator: "#56b6c2",
|
||||
type: "#e5c07b",
|
||||
functionName: "#61afef",
|
||||
variable: "#e06c75",
|
||||
string: "#98c379",
|
||||
number: "#d19a66",
|
||||
comment: "#5c6370",
|
||||
punctuation: "#abb2bf",
|
||||
property: "#ee939b",
|
||||
constant: "#f0ad7f",
|
||||
tag: "#ee939b",
|
||||
attribute: "#f0ad7f",
|
||||
escape: "#9bbbdd",
|
||||
markdownCode: "#99e89b",
|
||||
property: "#e06c75",
|
||||
constant: "#d19a66",
|
||||
tag: "#e06c75",
|
||||
attribute: "#d19a66",
|
||||
escape: "#56b6c2",
|
||||
markdownCode: "#98c379",
|
||||
markdownHeading: "#56b6c2",
|
||||
markdownMuted: "#808080",
|
||||
markdownItalic: "#dfca7d",
|
||||
markdownLink: "#56b6c2",
|
||||
markdownItalic: "#e5c07b",
|
||||
},
|
||||
light: {
|
||||
keyword: "#cf222e",
|
||||
@@ -69,7 +67,9 @@ 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 buildSyntaxStyle(
|
||||
theme: TerminalTheme,
|
||||
mode: SyntaxAccentMode,
|
||||
): SyntaxStyle {
|
||||
function underline(hex: string): StyleDefinition {
|
||||
return { fg: color(hex), underline: true };
|
||||
}
|
||||
|
||||
function buildSyntaxStyle(theme: TerminalTheme): SyntaxStyle {
|
||||
const colors = syntaxColors[theme];
|
||||
const accent = color(themePalette[theme][mode]);
|
||||
const markdownHeading = accent;
|
||||
const markdownHeading = color(colors.markdownHeading);
|
||||
const markdownCode = color(colors.markdownCode);
|
||||
const markdownMuted = color(colors.markdownMuted);
|
||||
const markdownLink = accent;
|
||||
const markdownLink = color(colors.markdownLink);
|
||||
|
||||
return SyntaxStyle.fromStyles({
|
||||
...(colors.markdownDefault ? { default: fg(colors.markdownDefault) } : {}),
|
||||
@@ -145,19 +145,10 @@ function buildSyntaxStyle(
|
||||
"markup.link.url": { fg: markdownLink, underline: true },
|
||||
label: { fg: markdownLink },
|
||||
conceal: { fg: markdownMuted },
|
||||
"string.special.url": { fg: markdownLink, underline: true },
|
||||
"string.special.url": underline(colors.markdownLink),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
export function getSyntaxStyle(theme: TerminalTheme = "dark"): SyntaxStyle {
|
||||
return (instances[theme] ??= buildSyntaxStyle(theme));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
useTerminalTheme,
|
||||
} from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getInputRuleColor,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
getModeInputForeground,
|
||||
@@ -77,7 +76,6 @@ 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 =
|
||||
@@ -125,10 +123,10 @@ export function ChatView(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<box>
|
||||
<box marginBottom={1}>
|
||||
<InputBar
|
||||
accent={accent}
|
||||
ruleColor={inputRuleColor}
|
||||
inputBackground={inputBackground}
|
||||
inputForeground={inputForeground}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -721,7 +721,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg={palette.act}>
|
||||
<text fg="cyan">
|
||||
<strong>Settings</strong>
|
||||
</text>
|
||||
|
||||
@@ -793,7 +793,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={isSel ? palette.act : undefined}>{pfx}Provider</text>
|
||||
<text fg={isSel ? "cyan" : 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 ? palette.act : undefined}>{pfx}Model</text>
|
||||
<text fg={isSel ? "cyan" : 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 ? palette.act : undefined}>
|
||||
<text fg={isSel ? "cyan" : undefined}>
|
||||
{pfx}
|
||||
{row.label}
|
||||
</text>
|
||||
@@ -866,7 +866,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
: isSel
|
||||
? palette.act
|
||||
? "cyan"
|
||||
: "gray";
|
||||
return (
|
||||
<box
|
||||
@@ -886,7 +886,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
case "mcp-manager":
|
||||
return (
|
||||
<text key={absIdx} fg={isSel ? palette.act : "gray"}>
|
||||
<text key={absIdx} fg={isSel ? "cyan" : "gray"}>
|
||||
{pfx}Manage MCP Servers...
|
||||
</text>
|
||||
);
|
||||
|
||||
@@ -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 inputRuleColor = getInputRuleColor(terminalBg);
|
||||
const inputBackground = getModeInputBackground(session.uiMode, 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) + 2;
|
||||
const inputStartX = Math.floor((width - contentWidth) / 2) + 4;
|
||||
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}
|
||||
ruleColor={inputRuleColor}
|
||||
inputBackground={inputBackground}
|
||||
inputForeground={inputForeground}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -10,24 +10,16 @@ import {
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import open from "open";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
} from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
import {
|
||||
loadCurrentUserPlanFromProviderSettings,
|
||||
loadIndividualSubscriptionPlansFromProviderSettings,
|
||||
} from "../../cline-account";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -56,9 +48,6 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
@@ -89,7 +78,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
getMainMenuOptions({
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -160,19 +150,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [modelsDefaultId, setModelsDefaultId] = useState("");
|
||||
const [customModelId, setCustomModelId] = useState("");
|
||||
const [customModelError, setCustomModelError] = useState("");
|
||||
const [clinePassSubscriptionStatus, setClinePassSubscriptionStatus] =
|
||||
useState<ClinePassSubscriptionStatus>("loading");
|
||||
const [clinePassSubscriptionError, setClinePassSubscriptionError] =
|
||||
useState("");
|
||||
const [clinePassCurrentPlanName, setClinePassCurrentPlanName] = useState("");
|
||||
const [clinePassPlanFeatures, setClinePassPlanFeatures] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [clinePassSubscriptionSelected, setClinePassSubscriptionSelected] =
|
||||
useState(0);
|
||||
const [clinePassSubscriptionOpenStatus, setClinePassSubscriptionOpenStatus] =
|
||||
useState("");
|
||||
const clinePassSubscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
|
||||
const modelItems: SearchableItem[] = useMemo(
|
||||
() =>
|
||||
@@ -238,9 +215,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
const [thinkingSelected, setThinkingSelected] = useState(
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
);
|
||||
const [thinkingSelected, setThinkingSelected] = useState(0);
|
||||
const [selectedModelName, setSelectedModelName] = useState("");
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
const [selectedThinking, setSelectedThinking] = useState(false);
|
||||
@@ -290,62 +265,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
[providerSettingsManager],
|
||||
);
|
||||
|
||||
const refreshClinePassSubscriptionStatus = useCallback(() => {
|
||||
setClinePassSubscriptionStatus("loading");
|
||||
setClinePassSubscriptionError("");
|
||||
setClinePassCurrentPlanName("");
|
||||
setClinePassSubscriptionOpenStatus("");
|
||||
|
||||
loadCurrentUserPlanFromProviderSettings({ providerSettingsManager })
|
||||
.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
.then((currentPlanResult) =>
|
||||
loadIndividualSubscriptionPlansFromProviderSettings({
|
||||
providerSettingsManager,
|
||||
})
|
||||
.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
.then((availablePlansResult) => ({
|
||||
availablePlansResult,
|
||||
currentPlanResult,
|
||||
})),
|
||||
)
|
||||
.then(({ currentPlanResult, availablePlansResult }) => {
|
||||
if (availablePlansResult.status === "fulfilled") {
|
||||
setClinePassPlanFeatures(
|
||||
getIndividualPlanFeatures(availablePlansResult.value),
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPlanResult.status === "rejected") {
|
||||
const error = currentPlanResult.reason;
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (message.trim().toLowerCase() === "no plan found for user") {
|
||||
setClinePassSubscriptionStatus("unsubscribed");
|
||||
return;
|
||||
}
|
||||
setClinePassSubscriptionError(message);
|
||||
setClinePassSubscriptionStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = currentPlanResult.value?.plan;
|
||||
if (plan) {
|
||||
setClinePassCurrentPlanName(
|
||||
plan.displayName || plan.name || plan.id || "ClinePass",
|
||||
);
|
||||
setClinePassSubscriptionStatus("subscribed");
|
||||
} else {
|
||||
setClinePassSubscriptionStatus("unsubscribed");
|
||||
}
|
||||
});
|
||||
}, [providerSettingsManager]);
|
||||
|
||||
const transitionToModelPicker = useCallback(
|
||||
(providerId: string) => {
|
||||
setActiveProviderId(providerId);
|
||||
@@ -369,27 +288,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
[providers, loadModelsForProvider, providerSettingsManager],
|
||||
);
|
||||
|
||||
const transitionToClinePassSubscription = useCallback(() => {
|
||||
setActiveProviderId("cline-pass");
|
||||
const provider = providers.find((p) => p.id === "cline-pass");
|
||||
setActiveProviderName(provider?.name ?? "ClinePass");
|
||||
setModelsDefaultId(provider?.defaultModelId ?? "");
|
||||
setClinePassSubscriptionSelected(0);
|
||||
setStep("cline_pass_subscription");
|
||||
refreshClinePassSubscriptionStatus();
|
||||
}, [providers, refreshClinePassSubscriptionStatus]);
|
||||
|
||||
const handleAuthComplete = useCallback(
|
||||
(providerId: OnboardingOAuthProviderId) => {
|
||||
if (providerId === "cline-pass") {
|
||||
transitionToClinePassSubscription();
|
||||
return;
|
||||
}
|
||||
transitionToModelPicker(providerId);
|
||||
},
|
||||
[transitionToClinePassSubscription, transitionToModelPicker],
|
||||
);
|
||||
|
||||
const resetAuth = useCallback(() => {
|
||||
setAuthStatus("");
|
||||
setAuthUrl("");
|
||||
@@ -415,11 +313,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setVerifyUrl: setDeviceVerifyUrl,
|
||||
setStatus: setDeviceStatus,
|
||||
setError: setDeviceError,
|
||||
onComplete: handleAuthComplete,
|
||||
onComplete: transitionToModelPicker,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[providerSettingsManager, handleAuthComplete],
|
||||
[providerSettingsManager, transitionToModelPicker],
|
||||
);
|
||||
|
||||
const startOAuthFlow = useCallback(
|
||||
@@ -441,46 +339,18 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setStatus: setAuthStatus,
|
||||
setAuthUrl,
|
||||
setError: setAuthError,
|
||||
onComplete: handleAuthComplete,
|
||||
onComplete: transitionToModelPicker,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[
|
||||
providerSettingsManager,
|
||||
resetAuth,
|
||||
handleAuthComplete,
|
||||
transitionToModelPicker,
|
||||
startDeviceCodeFlow,
|
||||
],
|
||||
);
|
||||
|
||||
const continueFromClinePassSubscription = useCallback(() => {
|
||||
transitionToModelPicker("cline-pass");
|
||||
}, [transitionToModelPicker]);
|
||||
|
||||
const openClinePassSubscriptionPage = useCallback(() => {
|
||||
setClinePassSubscriptionOpenStatus("Opening subscription page...");
|
||||
void open(clinePassSubscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setClinePassSubscriptionOpenStatus(
|
||||
"Opened subscription page in your browser.",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setClinePassSubscriptionOpenStatus(
|
||||
`Could not open browser automatically. Open ${clinePassSubscriptionUrl}`,
|
||||
);
|
||||
});
|
||||
}, [clinePassSubscriptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
step === "cline_pass_subscription" &&
|
||||
clinePassSubscriptionStatus === "subscribed"
|
||||
) {
|
||||
transitionToModelPicker("cline-pass");
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
@@ -644,7 +514,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const entry = modelEntries.find((m) => m.id === modelId);
|
||||
if (entry?.supportsReasoning) {
|
||||
setSelectedModelName(entry.name);
|
||||
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
|
||||
setThinkingSelected(0);
|
||||
setStep("thinking_level");
|
||||
} else {
|
||||
setStep("done");
|
||||
@@ -694,7 +564,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setSelectedModelId(modelId);
|
||||
if (clineModelReasoningIds.has(modelId)) {
|
||||
setSelectedModelName(modelName);
|
||||
setThinkingSelected(DEFAULT_THINKING_LEVEL_INDEX);
|
||||
setThinkingSelected(0);
|
||||
setStep("thinking_level");
|
||||
} else {
|
||||
setStep("done");
|
||||
@@ -762,9 +632,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
modelList,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
thinkingSelected,
|
||||
setStep,
|
||||
setMenuSelected,
|
||||
@@ -781,11 +648,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setDeviceError,
|
||||
setDeviceStatus,
|
||||
setClineModelSelected,
|
||||
setClinePassSubscriptionSelected,
|
||||
setThinkingSelected,
|
||||
continueFromClinePassSubscription,
|
||||
refreshClinePassSubscriptionStatus,
|
||||
openClinePassSubscriptionPage,
|
||||
abortOAuth: () => {
|
||||
authAbortRef.current = true;
|
||||
},
|
||||
@@ -807,7 +670,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
return {
|
||||
activeProviderName,
|
||||
activeProviderId,
|
||||
authError,
|
||||
authStatus,
|
||||
authUrl,
|
||||
@@ -820,14 +682,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
clinePassPlanFeatures,
|
||||
clinePassSubscriptionError,
|
||||
clinePassSubscriptionOpenStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionUrl,
|
||||
deviceError,
|
||||
deviceStatus,
|
||||
deviceUserCode,
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
type OnboardingStep,
|
||||
THINKING_LEVELS,
|
||||
@@ -28,9 +26,6 @@ export function useOnboardingKeyboard(input: {
|
||||
modelList: SearchableListState;
|
||||
clineEntries: ClineModelPickerEntry[];
|
||||
clineModelSelected: number;
|
||||
clinePassSubscriptionStatus: ClinePassSubscriptionStatus;
|
||||
clinePassSubscriptionOptions: ClinePassSubscriptionOption[];
|
||||
clinePassSubscriptionSelected: number;
|
||||
thinkingSelected: number;
|
||||
setStep: (step: OnboardingStep) => void;
|
||||
setMenuSelected: Dispatch<SetStateAction<number>>;
|
||||
@@ -43,11 +38,7 @@ export function useOnboardingKeyboard(input: {
|
||||
setDeviceError: (value: string) => void;
|
||||
setDeviceStatus: (value: string) => void;
|
||||
setClineModelSelected: Dispatch<SetStateAction<number>>;
|
||||
setClinePassSubscriptionSelected: Dispatch<SetStateAction<number>>;
|
||||
setThinkingSelected: Dispatch<SetStateAction<number>>;
|
||||
continueFromClinePassSubscription: () => void;
|
||||
refreshClinePassSubscriptionStatus: () => void;
|
||||
openClinePassSubscriptionPage: () => void;
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
@@ -102,11 +93,6 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
if (input.step === "cline_pass_subscription") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "cline_model") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
@@ -149,43 +135,6 @@ export function useOnboardingKeyboard(input: {
|
||||
|
||||
if (input.step === "device_code") return;
|
||||
|
||||
if (input.step === "cline_pass_subscription") {
|
||||
const total = input.clinePassSubscriptionOptions.length;
|
||||
if (total === 0) return;
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
input.setClinePassSubscriptionSelected((s) =>
|
||||
s <= 0 ? total - 1 : s - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
||||
input.setClinePassSubscriptionSelected((s) =>
|
||||
s >= total - 1 ? 0 : s + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const option =
|
||||
input.clinePassSubscriptionOptions[
|
||||
Math.min(input.clinePassSubscriptionSelected, total - 1)
|
||||
];
|
||||
if (!option) return;
|
||||
if (option.value === "subscribe") {
|
||||
input.openClinePassSubscriptionPage();
|
||||
} else if (option.value === "refresh") {
|
||||
if (input.clinePassSubscriptionStatus !== "loading") {
|
||||
input.refreshClinePassSubscriptionStatus();
|
||||
}
|
||||
} else if (option.value === "skip") {
|
||||
input.continueFromClinePassSubscription();
|
||||
} else if (option.value === "back") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "menu") {
|
||||
if (key.name === "up") {
|
||||
input.setMenuSelected((s) =>
|
||||
|
||||
@@ -8,7 +8,6 @@ export type OnboardingStep =
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
| "custom_model_id"
|
||||
@@ -30,10 +29,6 @@ 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;
|
||||
@@ -41,17 +36,6 @@ export interface MenuOption {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionAction =
|
||||
| "subscribe"
|
||||
| "refresh"
|
||||
| "skip"
|
||||
| "back";
|
||||
|
||||
export interface ClinePassSubscriptionOption {
|
||||
value: ClinePassSubscriptionAction;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const MAIN_MENU: MenuOption[] = [
|
||||
{
|
||||
label: "Sign in with Cline",
|
||||
@@ -87,25 +71,6 @@ export function getMainMenuOptions(options?: {
|
||||
);
|
||||
}
|
||||
|
||||
export const CLINE_PASS_SUBSCRIPTION_OPTIONS: ClinePassSubscriptionOption[] = [
|
||||
{
|
||||
value: "subscribe",
|
||||
label: "Subscribe to ClinePass",
|
||||
},
|
||||
{
|
||||
value: "refresh",
|
||||
label: "Re-check subscription status",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip for now",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Go back",
|
||||
},
|
||||
];
|
||||
|
||||
export interface OnboardingResult {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -131,12 +96,6 @@ export interface ModelEntry {
|
||||
supportsReasoning: boolean;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionStatus =
|
||||
| "loading"
|
||||
| "subscribed"
|
||||
| "unsubscribed"
|
||||
| "error";
|
||||
|
||||
export interface ProviderCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import "opentui-spinner/react";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
@@ -19,18 +17,10 @@ import {
|
||||
TrackedRobot,
|
||||
type useMouseTracker,
|
||||
} from "../../components/tracked-robot";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
import { type MenuOption, THINKING_LEVELS } from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
@@ -39,10 +29,6 @@ function useDefaultFg(): string | undefined {
|
||||
return getDefaultForeground(terminalBg);
|
||||
}
|
||||
|
||||
function getClinePassSubscriptionOptionId(index: number): string {
|
||||
return `cline-pass-subscription-option-${index}`;
|
||||
}
|
||||
|
||||
interface OnboardingFrameProps {
|
||||
children: ReactNode;
|
||||
compact: boolean;
|
||||
@@ -384,7 +370,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={palette.act} selectable>
|
||||
<text fg="cyan" selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
@@ -482,198 +468,6 @@ export function OnboardingClineModelScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
currentPlanName: string;
|
||||
error: string;
|
||||
mouse: MouseTrackerState;
|
||||
openStatus: string;
|
||||
options: ClinePassSubscriptionOption[];
|
||||
planFeatures: string[];
|
||||
selected: number;
|
||||
status: ClinePassSubscriptionStatus;
|
||||
subscriptionUrl: string;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const planAccent = getModeAccent("plan", terminalTheme);
|
||||
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const isLoading = props.status === "loading";
|
||||
const isSubscribed = props.status === "subscribed";
|
||||
const isError = props.status === "error";
|
||||
const bodyHeight = props.compact ? 17 : 19;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubscribed) {
|
||||
return;
|
||||
}
|
||||
const scrollSelectedOptionIntoView = () => {
|
||||
scrollRef.current?.scrollChildIntoView(
|
||||
getClinePassSubscriptionOptionId(props.selected),
|
||||
);
|
||||
};
|
||||
scrollSelectedOptionIntoView();
|
||||
queueMicrotask(scrollSelectedOptionIntoView);
|
||||
const timeout = setTimeout(scrollSelectedOptionIntoView, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSubscribed, props.selected]);
|
||||
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
contentWidth={props.contentWidth}
|
||||
mouse={props.mouse}
|
||||
>
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={isSubscribed ? palette.success : planAccent}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
height={bodyHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
<scrollbox
|
||||
ref={scrollRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
scrollY
|
||||
scrollX={false}
|
||||
viewportOptions={{ overflow: "hidden" }}
|
||||
contentOptions={{ flexDirection: "column" }}
|
||||
>
|
||||
<box flexDirection="column" width="100%" flexShrink={0}>
|
||||
<text
|
||||
fg={isSubscribed ? palette.success : planAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSubscribed
|
||||
? "ClinePass subscription active"
|
||||
: "ClinePass subscription required"}
|
||||
</text>
|
||||
|
||||
{isLoading ? (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">Checking your ClinePass subscription...</text>
|
||||
</box>
|
||||
) : isSubscribed ? (
|
||||
<text fg={defaultFg} selectable flexShrink={0}>
|
||||
Current plan: {props.currentPlanName || "ClinePass"}
|
||||
</text>
|
||||
) : isError ? (
|
||||
<text
|
||||
fg={defaultFg}
|
||||
selectable
|
||||
flexShrink={0}
|
||||
content="Could not verify your ClinePass subscription. Re-check before choosing a ClinePass model."
|
||||
/>
|
||||
) : (
|
||||
<text
|
||||
fg={defaultFg}
|
||||
selectable
|
||||
flexShrink={0}
|
||||
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.status === "error" &&
|
||||
props.error &&
|
||||
props.error !== "no plan found for user" && (
|
||||
<text fg="red" selectable flexShrink={0}>
|
||||
{props.error}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{!isSubscribed && props.planFeatures.length > 0 && (
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
{props.planFeatures.map((feature) => {
|
||||
if (
|
||||
feature === "Low cost subscription pricing" ||
|
||||
feature === "Generous limits and reliable access" ||
|
||||
feature === "Built for as many programmers as possible"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
key={feature}
|
||||
fg={defaultFg}
|
||||
selectable
|
||||
flexShrink={0}
|
||||
>
|
||||
<span fg="green">✓ </span>
|
||||
<span>{feature}</span>
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
)}
|
||||
|
||||
{!isSubscribed && (
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
{props.options.map((option, i) => {
|
||||
const isSel = i === props.selected;
|
||||
return (
|
||||
<box
|
||||
id={getClinePassSubscriptionOptionId(i)}
|
||||
key={option.value}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : defaultFg}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
)}
|
||||
|
||||
{props.openStatus && (
|
||||
<text fg="gray" selectable flexShrink={0}>
|
||||
{props.openStatus}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{!isSubscribed && (
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
<text fg="gray" flexShrink={0}>
|
||||
If the browser button does not work:
|
||||
</text>
|
||||
<text fg={palette.act} selectable flexShrink={0}>
|
||||
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
<text fg="gray" paddingX={1}>
|
||||
<em>↑/↓ navigate, Enter to select, Esc to go back, Ctrl+C to exit</em>
|
||||
</text>
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingModelPickerScreen(props: {
|
||||
activeProviderName: string;
|
||||
compact: boolean;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useOnboardingController } from "./controller";
|
||||
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
@@ -122,24 +121,6 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "cline_pass_subscription") {
|
||||
return (
|
||||
<OnboardingClinePassSubscriptionScreen
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
currentPlanName={state.clinePassCurrentPlanName}
|
||||
error={state.clinePassSubscriptionError}
|
||||
mouse={mouse}
|
||||
openStatus={state.clinePassSubscriptionOpenStatus}
|
||||
options={state.clinePassSubscriptionOptions}
|
||||
planFeatures={state.clinePassPlanFeatures}
|
||||
selected={state.clinePassSubscriptionSelected}
|
||||
status={state.clinePassSubscriptionStatus}
|
||||
subscriptionUrl={state.clinePassSubscriptionUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "model_picker") {
|
||||
return (
|
||||
<OnboardingModelPickerScreen
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
@@ -11,11 +10,9 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
@@ -24,14 +21,6 @@ export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
|
||||
@@ -15,14 +15,12 @@ function createConfig(compaction?: Config["compaction"]): Config {
|
||||
}
|
||||
|
||||
describe("CLI compaction mode helpers", () => {
|
||||
it("defaults enabled compaction to basic truncation", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
|
||||
it("defaults enabled compaction to agentic summarization", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
|
||||
expect(getCliCompactionMode(createConfig())).toBe(
|
||||
DEFAULT_CLI_COMPACTION_MODE,
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
|
||||
"Truncation",
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
@@ -46,13 +44,13 @@ describe("CLI compaction mode helpers", () => {
|
||||
|
||||
it("builds default and explicit core compaction config", () => {
|
||||
expect(buildCliCompactionConfig()).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
});
|
||||
expect(buildCliCompactionConfig("agentic")).toEqual({
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
});
|
||||
expect(buildCliCompactionConfig("basic")).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
});
|
||||
expect(buildCliCompactionConfig("off")).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
@@ -79,8 +77,8 @@ describe("CLI compaction mode helpers", () => {
|
||||
});
|
||||
|
||||
it("cycles TUI choices in a stable order", () => {
|
||||
expect(getNextCliCompactionMode("basic")).toBe("agentic");
|
||||
expect(getNextCliCompactionMode("agentic")).toBe("off");
|
||||
expect(getNextCliCompactionMode("off")).toBe("basic");
|
||||
expect(getNextCliCompactionMode("agentic")).toBe("basic");
|
||||
expect(getNextCliCompactionMode("basic")).toBe("off");
|
||||
expect(getNextCliCompactionMode("off")).toBe("agentic");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { CliCompactionMode, Config } from "./types";
|
||||
|
||||
export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
|
||||
export const CLI_COMPACTION_MODES = ["agentic", "basic", "off"] as const;
|
||||
|
||||
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
|
||||
CliCompactionMode,
|
||||
"agentic" | "basic"
|
||||
> = "basic";
|
||||
> = "agentic";
|
||||
|
||||
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
|
||||
agentic: "agentic",
|
||||
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
|
||||
} as const satisfies Record<CliCompactionMode, string>;
|
||||
|
||||
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
|
||||
"Context compaction mode: agentic|basic|off (default: basic)";
|
||||
"Context compaction mode: agentic|basic|off (default: agentic)";
|
||||
|
||||
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
|
||||
|
||||
@@ -43,8 +43,8 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
|
||||
if (config.compaction?.enabled === false) {
|
||||
return "off";
|
||||
}
|
||||
return config.compaction?.strategy === "agentic"
|
||||
? "agentic"
|
||||
return config.compaction?.strategy === "basic"
|
||||
? "basic"
|
||||
: DEFAULT_CLI_COMPACTION_MODE;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import { handleEvent, handleTeamEvent, resolveStatusNoticeLabel } from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
@@ -160,6 +160,18 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toContain("── aborted (2 iterations) ──");
|
||||
});
|
||||
|
||||
it("uses stable copy for compaction status notices", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
reason: "auto_compaction",
|
||||
message: "Summarizing context...",
|
||||
} as AgentEvent),
|
||||
).toBe("Compacting context...");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -28,7 +28,7 @@ export function resolveStatusNoticeLabel(
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
return "Compacting context...";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function truncate(str: string, maxLen: number): string {
|
||||
return `${oneLine.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function formatStructuredCommand(cmd: unknown): string {
|
||||
function formatStructuredCommand(cmd: unknown): string {
|
||||
if (typeof cmd === "string") {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
@@ -12,13 +13,20 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("enables ClinePass when listing the SDK provider list", async () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled: true,
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,17 +77,12 @@ function getOwnServerRecord(
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(
|
||||
mutate: (servers: Record<string, unknown>) => void,
|
||||
): void {
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
@@ -103,9 +98,7 @@ export function removeServer(name: string): boolean {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
@@ -133,9 +126,7 @@ export function clearServerOAuth(name: string): void {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
@@ -104,9 +103,7 @@ export async function handleDesktopCommand(
|
||||
): Promise<unknown> {
|
||||
if (command === "list_provider_catalog") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
return await listLocalProviders(providerSettingsManager);
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
@@ -168,11 +165,6 @@ export async function handleDesktopCommand(
|
||||
providerId,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(providerSettingsManager, providerId, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -85,8 +85,7 @@ export function setMcpServerDisabled(
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
@@ -129,8 +128,7 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -145,8 +143,7 @@ export function deleteMcpServer(name: string): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
@@ -100,9 +99,7 @@ export async function sendProviderCatalog(
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const payload = await listLocalProviders(providerSettingsManager);
|
||||
ctx.send(peer, {
|
||||
type: "provider_catalog",
|
||||
providers: payload.providers,
|
||||
@@ -141,11 +138,6 @@ 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,
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I'll inspect the file." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "src/index.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "result-block-1",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "I'll inspect the file.",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: { path: "src/index.ts" },
|
||||
output: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "I'll inspect the file.",
|
||||
},
|
||||
{
|
||||
id: "assistant-1:tool:toolu_1",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
output: "export const value = 1;",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates error tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "missing.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "File not found",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-error",
|
||||
output: "File not found",
|
||||
error: "File not found",
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "toolu_1",
|
||||
state: "output-error",
|
||||
error: "File not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates orphan tool results as standalone meta tool blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_orphan",
|
||||
name: "read_file",
|
||||
content: "orphan output",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "user-1",
|
||||
role: "meta",
|
||||
text: "",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_orphan",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "user-1:tool:toolu_orphan",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_orphan",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates plain string content as a text block", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Plain response",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "Plain response",
|
||||
reasoning: undefined,
|
||||
reasoningRedacted: undefined,
|
||||
toolEvents: undefined,
|
||||
blocks: [
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "Plain response",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates same-message tool-call and tool-result blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
input: { query: "cline" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "call_1",
|
||||
name: "search",
|
||||
state: "output-available",
|
||||
input: { query: "cline" },
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks).toHaveLength(1);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "call_1",
|
||||
state: "output-available",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import type {
|
||||
WebviewActionSessionSummary,
|
||||
WebviewChatMessage,
|
||||
@@ -10,7 +9,6 @@ import type { HubContext } from "./state";
|
||||
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
asTimestamp,
|
||||
basename,
|
||||
@@ -90,297 +88,27 @@ function summarizeClient(client: TrackedClient): {
|
||||
};
|
||||
}
|
||||
|
||||
type HistoryToolLocation = {
|
||||
messageIndex: number;
|
||||
blockIndex: number;
|
||||
};
|
||||
|
||||
function historyContentParts(content: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => asRecord(part))
|
||||
.filter((part): part is Record<string, unknown> => Boolean(part));
|
||||
}
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function blockType(block: Record<string, unknown>): string {
|
||||
return asString(block.type)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function toolCallIdForCall(block: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(block.id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolCallIdForResult(
|
||||
block: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
return (
|
||||
asString(block.tool_use_id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolNameFor(block: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(block.name) ??
|
||||
asString(block.toolName) ??
|
||||
asString(block.tool_name) ??
|
||||
"tool"
|
||||
);
|
||||
}
|
||||
|
||||
function toolInputFor(block: Record<string, unknown>): unknown {
|
||||
return block.input ?? block.args ?? block.arguments;
|
||||
}
|
||||
|
||||
function toolOutputFor(block: Record<string, unknown>): unknown {
|
||||
return block.output ?? block.result ?? block.content;
|
||||
}
|
||||
|
||||
function isErrorToolResult(block: Record<string, unknown>): boolean {
|
||||
return (
|
||||
block.is_error === true || block.isError === true || block.error === true
|
||||
);
|
||||
}
|
||||
|
||||
function pushTextBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
textParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (!text) return;
|
||||
textParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:text:${partIndex}`,
|
||||
type: "text",
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
function pushReasoningBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
reasoningParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
redacted?: boolean,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
reasoningParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:reasoning:${partIndex}`,
|
||||
type: "reasoning",
|
||||
text,
|
||||
redacted,
|
||||
});
|
||||
return redacted === true;
|
||||
}
|
||||
|
||||
export function mapHistoryToWebviewMessages(
|
||||
history: unknown[],
|
||||
): WebviewChatMessage[] {
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<string, HistoryToolLocation>();
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
return history.map((entry, index) => {
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const messageKey = asString(record.id) ?? `history-${index}`;
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
let role: WebviewChatMessage["role"] =
|
||||
const role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
? rawRole
|
||||
: "meta";
|
||||
const blocks: NonNullable<WebviewChatMessage["blocks"]> = [];
|
||||
const textParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const toolEvents = new Map<
|
||||
string,
|
||||
NonNullable<WebviewChatMessage["toolEvents"]>[number]
|
||||
>();
|
||||
const currentToolBlockIndexes = new Map<string, number>();
|
||||
let reasoningRedacted = false;
|
||||
|
||||
// Persisted user text arrives raw, including runtime-generated
|
||||
// <user_input>/<mode_notice> wrappers -- format at this display
|
||||
// boundary so the webview never renders them.
|
||||
const displayText = (text: string): string =>
|
||||
role === "user" ? formatDisplayUserInput(text) : text;
|
||||
|
||||
const contentParts = historyContentParts(record.content);
|
||||
if (contentParts.length === 0) {
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
pushTextBlock(blocks, textParts, messageKey, 0, displayText(text));
|
||||
}
|
||||
|
||||
for (const [partIndex, part] of contentParts.entries()) {
|
||||
const type = blockType(part);
|
||||
if (type === "text") {
|
||||
pushTextBlock(
|
||||
blocks,
|
||||
textParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
displayText(asString(part.text) ?? asString(part.content) ?? ""),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.thinking) ??
|
||||
asString(part.reasoning) ??
|
||||
asString(part.text) ??
|
||||
"",
|
||||
part.redacted === true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "redacted_thinking") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
"[redacted]",
|
||||
true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use" || type === "tool-call") {
|
||||
const toolCallId =
|
||||
toolCallIdForCall(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const toolEvent = {
|
||||
id: `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name,
|
||||
text: `Running ${name}...`,
|
||||
state: "input-available" as const,
|
||||
input: toolInputFor(part),
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
currentToolBlockIndexes.set(toolCallId, blocks.length - 1);
|
||||
toolLocations.set(toolCallId, {
|
||||
messageIndex: mapped.length,
|
||||
blockIndex: blocks.length - 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool-result") {
|
||||
const toolCallId =
|
||||
toolCallIdForResult(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const output = toolOutputFor(part);
|
||||
const isError = isErrorToolResult(part);
|
||||
const currentBlockIndex = currentToolBlockIndexes.get(toolCallId);
|
||||
const existingLocation = toolLocations.get(toolCallId);
|
||||
const existing =
|
||||
currentBlockIndex !== undefined
|
||||
? blocks[currentBlockIndex]
|
||||
: existingLocation !== undefined
|
||||
? mapped[existingLocation.messageIndex]?.blocks?.[
|
||||
existingLocation.blockIndex
|
||||
]
|
||||
: undefined;
|
||||
const existingToolEvent =
|
||||
existing?.type === "tool" ? existing.toolEvent : undefined;
|
||||
const toolEvent = {
|
||||
id: existingToolEvent?.id ?? `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name: existingToolEvent?.name ?? name,
|
||||
text: isError
|
||||
? `${existingToolEvent?.name ?? name} failed`
|
||||
: `${existingToolEvent?.name ?? name} completed`,
|
||||
state: isError
|
||||
? ("output-error" as const)
|
||||
: ("output-available" as const),
|
||||
input: existingToolEvent?.input,
|
||||
output,
|
||||
error: isError ? stringifyContent(output) : undefined,
|
||||
};
|
||||
|
||||
if (currentBlockIndex !== undefined && existing?.type === "tool") {
|
||||
blocks[currentBlockIndex] = {
|
||||
...existing,
|
||||
toolEvent,
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
} else if (
|
||||
existingLocation !== undefined &&
|
||||
existing?.type === "tool"
|
||||
) {
|
||||
const target = mapped[existingLocation.messageIndex];
|
||||
const targetBlocks = target.blocks;
|
||||
const targetBlock = targetBlocks?.[existingLocation.blockIndex];
|
||||
if (targetBlocks && targetBlock?.type === "tool") {
|
||||
targetBlocks[existingLocation.blockIndex] = {
|
||||
...targetBlock,
|
||||
toolEvent,
|
||||
};
|
||||
}
|
||||
target.toolEvents = (target.toolEvents ?? []).map((event) =>
|
||||
event.toolCallId === toolCallId ? toolEvent : event,
|
||||
);
|
||||
} else {
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join("\n");
|
||||
const toolEventList = [...toolEvents.values()];
|
||||
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!text && role === "user" && toolEventList.length > 0) {
|
||||
role = "meta";
|
||||
}
|
||||
mapped.push({
|
||||
id: messageKey,
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
return {
|
||||
id: asString(record.id) ?? `history-${index}`,
|
||||
role,
|
||||
text,
|
||||
reasoning:
|
||||
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function trackSession(record: unknown): TrackedSession | undefined {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageFrame({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-344", contentClassName)}>{children}</div>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ProviderListContent({
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Model Providers
|
||||
Models
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Configure model providers and choose which ones are available.{" "}
|
||||
|
||||
@@ -258,8 +258,8 @@ export function SettingsView({
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -386,7 +386,7 @@ export function SettingsView({
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
usesOAuth(selectedProvider)
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -13,51 +13,8 @@ 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 2–10 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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
@@ -10,11 +10,6 @@
|
||||
"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)"
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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,10 +20,6 @@ import type {
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -107,40 +103,7 @@ 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 ?? "",
|
||||
@@ -162,9 +125,6 @@ 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,
|
||||
@@ -176,63 +136,6 @@ 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 ?? "",
|
||||
@@ -460,13 +363,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -25,7 +30,6 @@ import {
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -36,26 +40,13 @@ 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,
|
||||
@@ -959,7 +950,7 @@ export async function handleCommand(
|
||||
if (command === "list_provider_catalog") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
await ensureCustomProvidersLoaded(manager);
|
||||
return await listLocalProviders(manager, { isClinePassEnabled: true });
|
||||
return await listLocalProviders(manager);
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -1039,45 +1030,12 @@ 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();
|
||||
@@ -1085,8 +1043,7 @@ export async function handleCommand(
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
@@ -1137,8 +1094,7 @@ export async function handleCommand(
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -1150,8 +1106,7 @@ export async function handleCommand(
|
||||
if (command === "delete_mcp_server") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
delete servers[String(args?.name ?? "")];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
@@ -1203,26 +1158,6 @@ 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) {
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
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,9 +4,6 @@ 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 () => {
|
||||
@@ -17,18 +14,7 @@ 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();
|
||||
@@ -53,20 +39,8 @@ 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",
|
||||
@@ -83,15 +57,6 @@ 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",
|
||||
@@ -102,20 +67,11 @@ 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 () => {
|
||||
@@ -192,8 +148,6 @@ 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,13 +5,10 @@ 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";
|
||||
@@ -389,7 +386,6 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
@@ -434,12 +430,6 @@ 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",
|
||||
@@ -692,17 +682,10 @@ 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",
|
||||
@@ -720,7 +703,6 @@ 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,
|
||||
@@ -734,6 +716,5 @@ export async function initializeSessionManager(
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
@@ -1,998 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_MODE,
|
||||
@@ -15,49 +14,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -71,29 +27,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -133,20 +66,13 @@ export function startServer(
|
||||
return { port: server.port };
|
||||
}
|
||||
|
||||
export function createFetchHandler(
|
||||
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({
|
||||
@@ -154,39 +80,15 @@ export function createFetchHandler(
|
||||
mode: SIDECAR_MODE,
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ headers: jsonHeaders(req) },
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
server.upgrade(req)
|
||||
) {
|
||||
if (url.pathname === "/transport" && 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) => {
|
||||
@@ -199,7 +101,7 @@ export function createFetchHandler(
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: jsonHeaders(req),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
AgentToolContext,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -104,7 +103,6 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user