Compare commits

..
171 changed files with 4899 additions and 8784 deletions
+19 -65
View File
@@ -27,10 +27,6 @@ permissions:
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/ext-vscode-test.yml
@@ -106,13 +102,7 @@ jobs:
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
exit 1
fi
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
echo "Using existing tag '$TAG'."
fi
git checkout --detach "$TAG_REF^{commit}"
@@ -180,60 +170,6 @@ jobs:
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: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
| head -n 1 || true
)
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between the matching version heading and the next release heading.
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
$0 == "## [" version "]" { found=1; next }
found && /^## \[/ { exit }
found { print }
END { if (!found) exit 1 }
' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -274,6 +210,24 @@ jobs:
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:
-39
View File
@@ -1,44 +1,5 @@
# Changelog
## [4.0.0]
### Added
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
### Changed
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
### Fixed
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
## [3.89.2]
### Fixed
-15
View File
@@ -1,20 +1,5 @@
# Cline CLI Changelog
## 3.0.30
- Added a token count to the status bar, shown alongside cost
- Added organization-specific error messages
- Added SAP AI Core provider support
- Refreshed the model catalog with the latest provider models
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
- Threaded proxy/CA-aware networking into the inference path
- Persisted Bedrock settings to providers.json
- Normalized JSON-like tool inputs by schema for more reliable tool calls
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
## 3.0.29
- Costs are now hidden for Cline free models
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.30",
"version": "3.0.29",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+10 -28
View File
@@ -1,27 +1,14 @@
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
} from "./mcp";
import { addServer } from "../wizards/mcp/settings";
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
installMcpServer: vi.fn((options) => {
const { name, transport, warnings } =
actual.buildMcpInstallTransport(options);
return {
name,
status: "installed",
transport,
warnings,
};
}),
};
});
vi.mock("../wizards/mcp/settings", () => ({
addServer: vi.fn(),
}));
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
@@ -229,17 +216,12 @@ describe("mcp install command", () => {
});
expect(code).toBe(0);
expect(installMcpServer).toHaveBeenCalledWith({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
expect(addServer).toHaveBeenCalledWith("docs", {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token",
},
});
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
+131 -16
View File
@@ -1,19 +1,16 @@
import {
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
import { addServer, type McpTransport } from "../wizards/mcp/settings";
export interface McpCommandIo {
writeln?: (text: string) => void;
writeErr: (text: string) => void;
}
export interface McpInstallOptions extends CoreMcpInstallOptions {
export interface McpInstallOptions {
name: string;
headers?: string[];
targetArgs?: string[];
transport?: string;
io?: McpCommandIo;
isTty?: boolean;
json?: boolean;
@@ -24,13 +21,13 @@ export interface McpInstallOptions extends CoreMcpInstallOptions {
export interface McpInstallDirectResult {
name: string;
status: "installed";
transport: McpServerTransportConfig;
transport: McpTransport;
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpServerTransportConfig["type"] {
): McpTransport["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
@@ -61,6 +58,72 @@ function assertValidUrl(url: string): void {
}
}
function parseHeader(value: string): [string, string] {
const separatorIndex = value.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${value}". Expected "Header-Name: header value".`,
);
}
const name = value.slice(0, separatorIndex).trim();
const headerValue = value.slice(separatorIndex + 1).trim();
if (!name || !headerValue) {
throw new Error(
`Invalid MCP header "${value}". Expected "Header-Name: header value".`,
);
}
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) {
throw new Error(`Invalid MCP header name "${name}".`);
}
return [name, headerValue];
}
function splitTargetArgsAndHeaders(input: {
headers?: string[];
targetArgs?: string[];
}): { headers: string[]; targetArgs: string[] } {
const headers = [...(input.headers ?? [])];
const targetArgs: string[] = [];
const args = input.targetArgs ?? [];
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === "--header") {
const value = args[index + 1];
if (!value) {
throw new Error("--header requires a value");
}
headers.push(value);
index++;
continue;
}
if (arg?.startsWith("--header=")) {
headers.push(arg.slice("--header=".length));
continue;
}
targetArgs.push(arg);
}
return { headers, targetArgs };
}
function buildHeaders(values: string[]): {
headers?: Record<string, string>;
warnings: string[];
} {
if (values.length === 0) return { warnings: [] };
const headers: Record<string, string> = {};
const warnings: string[] = [];
for (const value of values) {
const [name, headerValue] = parseHeader(value);
headers[name] = headerValue;
if (/<[^>]+>/.test(headerValue)) {
warnings.push(
`Header "${name}" looks like it contains a placeholder. Update it in MCP settings before using this server.`,
);
}
}
return { headers, warnings };
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
@@ -106,15 +169,67 @@ export function buildMcpInstallDefaults(options: {
};
}
export function buildMcpInstallTransport(options: {
headers?: string[];
name: string;
targetArgs?: string[];
transport?: string;
}): { name: string; transport: McpTransport; warnings: string[] } {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const { headers: rawHeaders, targetArgs } = splitTargetArgsAndHeaders({
headers: options.headers,
targetArgs: options.targetArgs,
});
const { headers, warnings } = buildHeaders(rawHeaders);
if (type === "stdio") {
if (rawHeaders.length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...args] = targetArgs;
if (!command?.trim()) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs --yes -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
return {
name,
transport: {
type,
command,
args: args.length > 0 ? args : undefined,
},
warnings,
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
transport: headers ? { type, url, headers } : { type, url },
warnings,
};
}
export function installMcpServerDirect(
options: McpInstallOptions,
): McpInstallDirectResult {
const result: McpInstallResult = installMcpServer(options);
const { name, transport, warnings } = buildMcpInstallTransport(options);
addServer(name, transport);
return {
name: result.name,
status: result.status,
transport: result.transport,
warnings: result.warnings,
name,
status: "installed",
transport,
warnings,
};
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
.option("-c, --cwd <path>", "Working directory")
.option(
"--thinking <level>",
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
)
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
.option(
+35 -131
View File
@@ -407,7 +407,7 @@ describe("runCli lightweight command dispatch", () => {
it("does not load interactive runtime for single-prompt mode", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
@@ -417,30 +417,6 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects a single bare positional prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: nonexistent-command",
),
);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Use "cline --help"'),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects multiple bare positional prompt tokens", async () => {
const consoleError = vi
.spyOn(console, "error")
@@ -454,7 +430,7 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: hello world",
"Unknown command or extra arguments: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
@@ -498,7 +474,7 @@ describe("runCli lightweight command dispatch", () => {
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
const { runCli } = await import("./main");
@@ -507,7 +483,7 @@ describe("runCli lightweight command dispatch", () => {
cwd: process.cwd(),
});
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
cwd: "/tmp/cline-worktree",
workspaceRoot: "/tmp/cline-worktree",
@@ -751,7 +727,7 @@ describe("runCli lightweight command dispatch", () => {
it("uses the bundled catalog path for single-prompt runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
@@ -1037,30 +1013,12 @@ describe("runCli lightweight command dispatch", () => {
it("skips hub prewarm for yolo runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("rejects yolo runs with a single bare prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: hello"),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
@@ -1084,12 +1042,12 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("rejects /team without quoted task text", async () => {
it("shows /team usage in single-prompt mode when no task is provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team"];
@@ -1097,10 +1055,9 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(mockState.runAgentCalls).toBe(0);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: /team"),
expect(stdoutWrite).toHaveBeenCalledWith(
expect.stringContaining("Usage: /team <task description>"),
);
});
@@ -1109,14 +1066,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1125,40 +1082,19 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("leaves thinking unset when --thinking is not provided", async () => {
it("leaves thinking disabled when --thinking is not provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
thinking: undefined,
reasoningEffort: undefined,
}),
expect.anything(),
);
});
it("disables thinking when --thinking none is explicitly provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1172,14 +1108,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1202,14 +1138,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1218,32 +1154,6 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("uses persisted disabled reasoning when --thinking is not provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
providerSettingsMocks.getProviderSettings.mockReturnValue({
provider: "cline",
model: "openai/gpt-5",
reasoning: { enabled: false },
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
}),
expect.anything(),
);
});
it("prefers explicit --thinking over persisted reasoning effort", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1254,14 +1164,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "low",
@@ -1275,13 +1185,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1297,13 +1207,13 @@ 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", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1319,19 +1229,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"agentic",
"say hello",
];
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1381,13 +1285,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: false,
@@ -1426,7 +1330,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
process.argv = ["bun", "src/index.ts", "--json", "hello"];
const { runCli } = await import("./main");
@@ -1434,7 +1338,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
@@ -1453,7 +1357,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
process.argv = ["bun", "src/index.ts", "--json", "hello"];
const { runCli } = await import("./main");
@@ -1461,7 +1365,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
+25 -29
View File
@@ -42,7 +42,6 @@ import {
isOAuthProvider,
normalizeProviderId,
} from "./utils/provider-auth";
import { resolveCliReasoning } from "./utils/reasoning";
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
@@ -117,19 +116,6 @@ function collectOption(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
// Shells strip quote characters before argv reaches us, so a prompt that was
// typed in quotes is only observable when it remains one argv token with spaces.
function promptArgLooksQuoted(arg: string | undefined): boolean {
return !!arg && /\s/.test(arg);
}
function writePromptArgError(args: string[]): void {
const renderedArgs = args.join(" ");
writeErr(
`Unknown command or unquoted prompt: ${renderedArgs}\nPrompt text must be passed as a single quoted argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
}
export async function runCli(): Promise<void> {
installStreamErrorGuards();
autoUpdateOnStartup();
@@ -749,6 +735,13 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
if (program.args.length > 1) {
writeErr(
`Unknown command or extra arguments: ${program.args.join(" ")}\nPrompt text with spaces must be quoted as a single argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
process.exitCode = 1;
return;
}
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
@@ -835,13 +828,6 @@ export async function runCli(): Promise<void> {
if (args.hooksDir?.trim()) {
process.env.CLINE_HOOKS_DIR = args.hooksDir.trim();
}
if (args.prompt && !args.interactive) {
if (program.args.length > 1 || !promptArgLooksQuoted(program.args[0])) {
writePromptArgError(program.args);
process.exitCode = 1;
return;
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
@@ -1025,12 +1011,19 @@ export async function runCli(): Promise<void> {
);
}
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
const resolvedReasoning = resolveCliReasoning({
thinking: args.thinking,
thinkingExplicitlySet: args.thinkingExplicitlySet,
reasoningEffort: args.reasoningEffort,
persistedReasoning: selectedProviderSettings?.reasoning,
});
const persistedReasoning = selectedProviderSettings?.reasoning;
const persistedReasoningEffort = persistedReasoning?.effort;
const reasoningEffortFromSettings =
persistedReasoning?.enabled === false
? "none"
: persistedReasoningEffort && persistedReasoningEffort !== "none"
? persistedReasoningEffort
: persistedReasoning?.enabled === true
? "medium"
: "none";
const effectiveReasoningEffort = args.thinkingExplicitlySet
? (args.reasoningEffort ?? "none")
: (args.reasoningEffort ?? reasoningEffortFromSettings);
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -1066,8 +1059,11 @@ export async function runCli(): Promise<void> {
sandbox: sandboxEnabled,
sandboxDataDir,
verbose: args.verbose,
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
thinking: effectiveReasoningEffort !== "none",
reasoningEffort:
effectiveReasoningEffort === "none"
? undefined
: effectiveReasoningEffort,
outputMode: args.outputMode,
mode: args.mode,
logger: loggerAdapter.core,
+1 -1
View File
@@ -40,7 +40,7 @@ const sessionEventsMocks = vi.hoisted(() => ({
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
"https://app.cline.bot/promo?code=CLI-100&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveReasoningForModelChange } from "./run-interactive";
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
expect(
resolveReasoningForModelChange(
{ thinking: false, reasoningEffort: undefined },
{ reasoning: { enabled: true, effort: "high" } },
),
).toEqual({ enabled: false });
});
it("persists enabled reasoning with the selected effort", () => {
expect(
resolveReasoningForModelChange(
{ thinking: true, reasoningEffort: "low" },
{ reasoning: { enabled: false } },
),
).toEqual({ enabled: true, effort: "low" });
});
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
expect(
resolveReasoningForModelChange(
{ thinking: true, reasoningEffort: undefined },
{ reasoning: { enabled: false } },
),
).toEqual({ enabled: true });
});
it("preserves existing reasoning when thinking is unset", () => {
expect(
resolveReasoningForModelChange(
{ thinking: undefined, reasoningEffort: undefined },
{ reasoning: { enabled: true, effort: "medium" } },
),
).toEqual({ enabled: true, effort: "medium" });
});
});
+3 -26
View File
@@ -8,7 +8,6 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
loadIndividualSubscriptionPlans,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
@@ -58,23 +57,6 @@ import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
import { buildUserInputMessage } from "./prompt";
import { getUIEventEmitter } from "./session-events";
type ModelChangeReasoningConfig = {
thinking?: boolean;
reasoningEffort?: Config["reasoningEffort"];
};
export function resolveReasoningForModelChange(
config: ModelChangeReasoningConfig,
existing: Pick<ProviderSettings, "reasoning">,
): ProviderSettings["reasoning"] {
if (config.thinking === false) return { enabled: false };
if (config.reasoningEffort) {
return { enabled: true, effort: config.reasoningEffort };
}
if (config.thinking === true) return { enabled: true };
return existing.reasoning;
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -428,12 +410,6 @@ export async function runInteractive(
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
}),
loadIndividualSubscriptionPlans: async () =>
await loadIndividualSubscriptionPlans({
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
clineProviderSettings: options?.clineProviderSettings,
}),
switchClineAccount: async (organizationId) =>
await switchClineAccount({
config,
@@ -661,11 +637,12 @@ export async function runInteractive(
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
reasoning: config.reasoningEffort
? { enabled: true, effort: config.reasoningEffort }
: { enabled: false },
});
await sessionRuntime.restartWithCurrentMessages();
},
+2 -8
View File
@@ -3,11 +3,7 @@ import { CLINE_BIN } from "./helpers/constants.js";
import { clineEnv } from "./helpers/env.js";
import { expectVisible } from "./helpers/terminal.js";
// Wide enough that long option descriptions (e.g. --thinking) render on a
// single line. At narrower widths commander wraps them, splitting phrases
// like "omitted leaves provider default" across lines so the contiguous
// getByText assertions below fail.
const HELP_TERMINAL = { columns: 200, rows: 50 };
const HELP_TERMINAL = { columns: 120, rows: 50 };
// ===========================================================================
// Root-level flag descriptions
@@ -27,9 +23,7 @@ test.describe("root flag descriptions", () => {
"verbose output",
"Working directory",
"Configuration directory",
"Set reasoning effort:",
"Bare --thinking uses medium",
"omitted leaves provider default",
"Set reasoning effort level",
"consecutive mistakes",
"Output messages as JSON",
"Check for updates and install if available",
-53
View File
@@ -12,7 +12,6 @@ const coreMocks = vi.hoisted(() => {
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
fetchAvailableSubscriptionPlans: vi.fn(),
serviceOptions,
};
});
@@ -40,11 +39,6 @@ vi.mock("@cline/core", async (importOriginal) => {
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
fetchAvailableSubscriptionPlans(input?: {
type?: "individual" | "teams";
}) {
return coreMocks.fetchAvailableSubscriptionPlans(input);
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -106,7 +100,6 @@ describe("createClineAccountService", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -203,7 +196,6 @@ describe("loadClineAccountSnapshot", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -257,48 +249,3 @@ describe("loadClineAccountSnapshot", () => {
);
});
});
describe("loadIndividualSubscriptionPlans", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads individual subscription plans through the authorized account service", async () => {
const plans = [
{
id: "plan-1",
interval: "Monthly",
features: { included: ["Major open-weights models"] },
},
];
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
apiKey: "account-token",
});
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
const result = await loadIndividualSubscriptionPlans({
config: makeConfig(),
});
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
type: "individual",
});
expect(result).toEqual(plans);
});
});
-13
View File
@@ -2,7 +2,6 @@ import {
type ClineAccountBalance,
type ClineAccountOrganization,
type ClineAccountOrganizationBalance,
type ClineSubscriptionPlan,
ClineAccountService,
type ClineAccountUser,
formatProviderOAuthApiKey,
@@ -204,18 +203,6 @@ export async function switchClineAccount(input: {
await service.switchAccount(input.organizationId);
}
export async function loadIndividualSubscriptionPlans(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
}): Promise<ClineSubscriptionPlan[]> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
+5 -71
View File
@@ -1,15 +1,13 @@
import type { ClineSubscriptionPlan } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
@@ -37,12 +35,6 @@ 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+/, "");
}
@@ -274,14 +266,7 @@ function ToolCallView(props: {
);
}
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="* " />
@@ -296,11 +281,7 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
<text
fg={props.defaultFg}
selectable
content={
isClinePassEnabled
? CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE
: OUT_OF_CREDITS_MESSAGE
}
content="You have run out of Cline credits. Add credits in the dashboard to continue."
/>
<box flexDirection="row">
<text fg="gray">Dashboard: </text>
@@ -315,34 +296,8 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
);
}
function ClinePassSubscriptionErrorView(props: {
defaultFg?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
}) {
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getCliSubscriptionUrl();
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
useEffect(() => {
if (!props.loadIndividualSubscriptionPlans) {
return;
}
let isMounted = true;
void props
.loadIndividualSubscriptionPlans()
.then((plans) => {
if (isMounted) {
setPlanFeatures(getIndividualPlanFeatures(plans));
}
})
.catch(() => {
// Keep the subscription error view usable if plan metadata is unavailable.
});
return () => {
isMounted = false;
};
}, [props.loadIndividualSubscriptionPlans]);
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
@@ -359,19 +314,6 @@ function ClinePassSubscriptionErrorView(props: {
selectable
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
{planFeatures.length > 0 && (
<box flexDirection="column" marginTop={1}>
<text fg={props.defaultFg}>ClinePass includes:</text>
{planFeatures.map((feature) => (
<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="cyan" selectable>
@@ -416,7 +358,6 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
const { entry, accent = palette.act, terminalTheme } = props;
@@ -520,14 +461,7 @@ export function ChatEntryView(props: {
);
}
if (isClinePassSubscriptionError(entry.text)) {
return (
<ClinePassSubscriptionErrorView
defaultFg={defaultFg}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
/>
);
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
@@ -1,5 +1,5 @@
import "opentui-spinner/react";
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
import type { AgentMode } from "@cline/core";
import type { ScrollBoxRenderable } from "@opentui/core";
import {
forwardRef,
@@ -21,7 +21,6 @@ export interface TranscriptScrollHandle {
interface ChatMessageListProps {
entries: ChatEntry[];
isStreaming?: boolean;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
uiMode?: AgentMode;
}
@@ -101,9 +100,6 @@ export const ChatMessageList = forwardRef<
key={key}
entry={entry}
accent={accent}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
/>
);
@@ -248,44 +248,18 @@ export function ProviderPickerContent(
);
}
export type ExistingProviderAction =
| "use_existing"
| "reconfigure"
| "open_subscription";
export interface ExistingProviderOption {
value: ExistingProviderAction;
label: string;
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 type ExistingProviderAction = "use_existing" | "reconfigure";
export function UseExistingOrReconfigureContent(
props: ChoiceContext<ExistingProviderOption> & {
props: ChoiceContext<ExistingProviderAction> & {
providerName: string;
extraOptions?: ExistingProviderOption[];
},
) {
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
const options: ExistingProviderOption[] = useMemo(
() => [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
...(extraOptions ?? []),
],
[extraOptions],
);
const { resolve, dismiss, dialogId, providerName } = props;
const options: { value: ExistingProviderAction; label: string }[] = [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
];
const [selected, setSelected] = useState(0);
useDialogKeyboard((key) => {
@@ -295,7 +269,7 @@ export function UseExistingOrReconfigureContent(
}
if (key.name === "return" || key.name === "enter") {
const opt = options[selected];
if (opt) resolve(opt);
if (opt) resolve(opt.value);
return;
}
if (key.name === "up" || (key.ctrl && key.name === "p")) {
@@ -340,59 +314,6 @@ export function UseExistingOrReconfigureContent(
);
}
export function ClinePassSubscriptionContent(
props: ChoiceContext<boolean> & {
providerName: string;
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const subscriptionUrl = useMemo(
() =>
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
[],
);
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
void open(subscriptionUrl, { wait: false })
.then(() => {
setStatus("Opened subscription page in your browser.");
})
.catch(() => {
setStatus("Could not open browser automatically. Open the URL below.");
});
}, [subscriptionUrl]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return" || key.name === "enter") {
resolve(true);
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<strong>{providerName}</strong>
</text>
<text>{status}</text>
<text fg="gray">Subscription page:</text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
<text fg="gray">
<em>Enter or Esc to go back</em>
</text>
</box>
);
}
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
@@ -55,18 +55,18 @@ describe("formatStatusBarUsageText", () => {
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline",
showCost: true,
}),
).toBe("(12,345 tokens) $0.12");
});
it("displays subscription message when the provider is a subscription provider", () => {
it("omits cost when usage cost is hidden", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline-pass",
showCost: false,
}),
).toBe("(12,345 tokens) $0.00 (included with your subscription)");
).toBe("(12,345 tokens)");
});
});
+6 -25
View File
@@ -1,9 +1,6 @@
import type { AgentMode } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
@@ -49,31 +46,14 @@ function formatCost(cost: number): string {
return `$${cost.toFixed(2)}`;
}
function formatCostText(providerId: string, totalCost: number): string {
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
return "$0.00 (included with your subscription)";
}
if (!shouldShowCliUsageCost(providerId)) {
return "";
}
return formatCost(totalCost);
}
export function formatStatusBarUsageText(input: {
totalTokens: number;
totalCost: number;
providerId: string;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
const costText = formatCostText(input.providerId, input.totalCost);
if (!costText) {
return tokens;
}
return `${tokens} ${costText}`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
@@ -172,6 +152,7 @@ export function StatusBar(props: StatusBarProps) {
const bar = hasMaxInputTokens
? createContextBar(totalTokens, maxInputTokens)
: undefined;
const showUsageCost = shouldShowCliUsageCost(props.providerId);
// Available content width after accounting for padding.
// Home view: parent box is capped at 60 wide, status bar adds paddingX=1 (-2).
@@ -188,7 +169,7 @@ export function StatusBar(props: StatusBarProps) {
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
providerId: props.providerId,
showCost: showUsageCost,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
+8 -58
View File
@@ -18,9 +18,8 @@ import {
import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
type ExistingProviderAction,
OAuthLoginContent,
ProviderConfigInputContent,
ProviderPickerContent,
@@ -79,36 +78,6 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
dialog: DialogActions;
termHeight: number;
}): ExistingProviderOption[] {
if (input.providerId !== "cline-pass") {
return [];
}
return [
{
value: "open_subscription",
label: "Open ClinePass subscription page",
onSelect: async () => {
await input.dialog.choice<boolean>({
style: { maxHeight: input.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<ClinePassSubscriptionContent
{...ctx}
providerName={input.providerName}
/>
),
});
},
},
];
}
async function runProviderChange(
dialog: DialogActions,
config: Config,
@@ -133,33 +102,14 @@ async function runProviderChange(
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
let option: ExistingProviderOption | undefined;
const extraOptions = providerToExistingProviderOptions({
providerId: newProviderId,
providerName: displayName,
dialog,
termHeight,
const action = await dialog.choice<ExistingProviderAction>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
),
});
while (true) {
option = await dialog.choice<ExistingProviderOption>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderOption>) => (
<UseExistingOrReconfigureContent
{...ctx}
providerName={displayName}
extraOptions={extraOptions}
/>
),
});
if (!option) return false;
if (option.onSelect) {
await option.onSelect();
option = undefined;
continue;
}
break;
}
needsAuth = option.value === "reconfigure";
if (!action) return false;
needsAuth = action === "reconfigure";
}
if (needsAuth) {
-1
View File
@@ -879,7 +879,6 @@ function App(props: TuiProps) {
repoStatus,
textareaRef: promptInput.textareaRef,
transcriptScrollRef,
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
queuedPrompts,
selectedQueuedPromptId,
editingQueuedPrompt,
-2
View File
@@ -2,7 +2,6 @@ import type {
AgentEvent,
AgentMode,
CheckpointEntry,
ClineSubscriptionPlan,
TeamEvent,
} from "@cline/core";
import type {
@@ -130,7 +129,6 @@ export interface TuiProps {
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
switchClineAccount: (organizationId?: string | null) => Promise<void>;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
-2
View File
@@ -50,7 +50,6 @@ export function ChatView(props: {
};
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
autocomplete?: AutocompleteDropdownProps;
queuedPrompts?: QueuedPromptItem[];
selectedQueuedPromptId?: string | null;
@@ -90,7 +89,6 @@ export function ChatView(props: {
ref={props.transcriptScrollRef}
entries={session.entries}
isStreaming={session.isStreaming}
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
uiMode={session.uiMode}
/>
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -27,7 +27,7 @@ describe("cline-pass-errors", () => {
it("formats the ClinePass subscription URL", () => {
expect(getCliSubscriptionUrl()).toBe(
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
"https://app.cline.bot/promo?code=CLI-100&personal=true",
);
});
+4 -2
View File
@@ -8,11 +8,13 @@ import {
import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export {
getClineOrgIndividualInferenceSubscriptionMessage,
};
export function getCliSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-8OFF&personal=true",
"/promo?code=CLI-100&personal=true",
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
-89
View File
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveCliReasoning } from "./reasoning";
describe("resolveCliReasoning", () => {
it("leaves reasoning unset when neither CLI nor persisted settings specify it", () => {
expect(
resolveCliReasoning({
thinking: false,
}),
).toEqual({
thinking: undefined,
reasoningEffort: undefined,
});
});
it("preserves explicit --thinking none as disabled reasoning", () => {
expect(
resolveCliReasoning({
thinking: false,
thinkingExplicitlySet: true,
}),
).toEqual({
thinking: false,
reasoningEffort: undefined,
});
});
it("prefers explicit --thinking over persisted reasoning settings", () => {
expect(
resolveCliReasoning({
thinking: true,
thinkingExplicitlySet: true,
reasoningEffort: "low",
persistedReasoning: { enabled: false },
}),
).toEqual({
thinking: true,
reasoningEffort: "low",
});
});
it("uses persisted disabled reasoning when --thinking is unset", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { enabled: false },
}),
).toEqual({
thinking: false,
reasoningEffort: undefined,
});
});
it("uses persisted effort none as disabled reasoning when --thinking is unset", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { effort: "none" },
}),
).toEqual({
thinking: false,
reasoningEffort: undefined,
});
});
it("uses persisted active effort when --thinking is unset", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { enabled: true, effort: "high" },
}),
).toEqual({
thinking: true,
reasoningEffort: "high",
});
});
it("uses medium effort when persisted reasoning is enabled without an effort", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { enabled: true },
}),
).toEqual({
thinking: true,
reasoningEffort: "medium",
});
});
});
-65
View File
@@ -1,65 +0,0 @@
import type { ProviderSettings } from "@cline/core";
import type { CliReasoningEffort } from "./types";
type ActiveCliReasoningEffort = Exclude<CliReasoningEffort, "none">;
const ACTIVE_REASONING_EFFORTS = new Set<ActiveCliReasoningEffort>([
"low",
"medium",
"high",
"xhigh",
]);
export interface ResolveCliReasoningInput {
thinking: boolean;
thinkingExplicitlySet?: boolean;
reasoningEffort?: CliReasoningEffort;
persistedReasoning?: ProviderSettings["reasoning"];
}
export interface ResolvedCliReasoning {
thinking?: boolean;
reasoningEffort?: ActiveCliReasoningEffort;
}
function isActiveReasoningEffort(
effort: unknown,
): effort is ActiveCliReasoningEffort {
return (
typeof effort === "string" &&
ACTIVE_REASONING_EFFORTS.has(effort as ActiveCliReasoningEffort)
);
}
export function resolveCliReasoning({
thinking,
thinkingExplicitlySet,
reasoningEffort,
persistedReasoning,
}: ResolveCliReasoningInput): ResolvedCliReasoning {
if (thinkingExplicitlySet) {
return {
thinking,
reasoningEffort: isActiveReasoningEffort(reasoningEffort)
? reasoningEffort
: undefined,
};
}
if (
persistedReasoning?.enabled === false ||
persistedReasoning?.effort === "none"
) {
return { thinking: false, reasoningEffort: undefined };
}
if (isActiveReasoningEffort(persistedReasoning?.effort)) {
return { thinking: true, reasoningEffort: persistedReasoning.effort };
}
if (persistedReasoning?.enabled === true) {
return { thinking: true, reasoningEffort: "medium" };
}
return { thinking: undefined, reasoningEffort: undefined };
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
timeoutSeconds?: number;
sandbox: boolean;
sandboxDataDir?: string;
thinking?: boolean;
thinking: boolean;
outputMode: CliOutputMode;
mode: CliAgentMode;
defaultToolAutoApprove: boolean;
-6
View File
@@ -3,9 +3,3 @@ import { Llms } from "@cline/core";
export function shouldShowCliUsageCost(providerId: string): boolean {
return Llms.shouldShowProviderUsageCost(providerId);
}
export function shouldShowCliUsageCoveredBySubscription(
providerId: string,
): boolean {
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
}
+29 -43
View File
@@ -51,36 +51,6 @@ describe("marketplace installer", () => {
vi.restoreAllMocks();
});
function createInstalledOfficialPlugin(
clineDir: string,
slug: string,
): string {
const sourceKey = `official:https://github.com/cline/plugins.git#plugins/${slug}`;
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
const installPath = join(
clineDir,
"plugins",
"_installed",
"official",
`${slug}-${hash}`,
);
mkdirSync(join(installPath, "package"), { recursive: true });
writeFileSync(
join(installPath, "package.json"),
JSON.stringify({ name: slug }, null, 2),
"utf8",
);
writeFileSync(
join(installPath, "package", "index.ts"),
`export default { name: "${slug}", manifest: { capabilities: ["tools"] } };`,
"utf8",
);
return installPath;
}
it("maps remote MCP catalog args to MCP settings shape", () => {
expect(
buildMarketplaceMcpInput([
@@ -318,6 +288,8 @@ describe("marketplace installer", () => {
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
@@ -563,13 +535,16 @@ describe("marketplace installer", () => {
]);
});
it("uninstalls official marketplace plugins through the shared core service", async () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
it("runs official plugin uninstalls through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
@@ -590,8 +565,12 @@ describe("marketplace installer", () => {
message: "Uninstalled Goal.",
});
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
@@ -635,12 +614,15 @@ describe("marketplace installer", () => {
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
@@ -668,8 +650,12 @@ describe("marketplace installer", () => {
},
);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
+103 -18
View File
@@ -18,11 +18,8 @@ import {
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";
@@ -795,6 +792,50 @@ async function installSkill(
};
}
async function uninstallSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installedName = findInstalledGlobalSkillName(entry);
if (!installedName) {
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `${entry.name ?? entry.id} is not installed.`,
};
}
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"remove",
installedName,
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
@@ -845,6 +886,47 @@ async function installPlugin(
};
}
async function uninstallPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
const target = installArgs[0]?.trim() || entry.id;
if (!target) {
throw new Error("Plugin marketplace uninstalls require a plugin name.");
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"uninstall",
target,
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin uninstall 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: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
@@ -901,21 +983,24 @@ export async function uninstallMarketplaceEntry(
): 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,
};
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = deleteMcpServer(String(input.name ?? ""));
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return uninstallSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return uninstallPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function installMarketplaceEntryFromCatalog(
+2 -1
View File
@@ -42,7 +42,8 @@
"activationEvents": [
"onLanguage",
"onUri",
"onStartupFinished"
"onStartupFinished",
"workspaceContains:evals.env"
],
"main": "./dist/extension.js",
"contributes": {
@@ -13,9 +13,7 @@ service MarketplaceService {
rpc listMarketplaceLocalInstalledEntries(EmptyRequest) returns (MarketplaceLocalInstalledEntries);
rpc listMarketplaceInstalledEntries(MarketplaceEntriesRequest) returns (MarketplaceInstalledEntries);
rpc installMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc uninstallMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc toggleMarketplaceLocalInstalledEntry(ToggleMarketplaceLocalInstalledEntryRequest) returns (MarketplaceLocalInstalledEntries);
rpc uninstallMarketplaceLocalInstalledEntry(MarketplaceLocalInstalledEntryRequest) returns (MarketplaceInstallResult);
}
message MarketplaceTag {
@@ -89,10 +87,6 @@ message ToggleMarketplaceLocalInstalledEntryRequest {
bool enabled = 2;
}
message MarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
}
message MarketplaceEntryRequest {
MarketplaceEntry entry = 1;
}
+57 -4
View File
@@ -397,7 +397,7 @@ message ModelsApiOptions {
optional bool azure_identity = 44;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -438,7 +438,7 @@ message ModelsApiOptions {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -568,6 +568,59 @@ message OcaCompatibleModelInfo {
optional string error = 2;
}
// API Provider enumeration
enum ApiProvider {
ANTHROPIC = 0;
OPENROUTER = 1;
BEDROCK = 2;
VERTEX = 3;
OPENAI = 4;
OLLAMA = 5;
LMSTUDIO = 6;
GEMINI = 7;
OPENAI_NATIVE = 8;
REQUESTY = 9;
TOGETHER = 10;
DEEPSEEK = 11;
QWEN = 12;
DOUBAO = 13;
MISTRAL = 14;
VSCODE_LM = 15;
CLINE = 16;
LITELLM = 17;
NEBIUS = 18;
FIREWORKS = 19;
ASKSAGE = 20;
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
ZAI = 31;
VERCEL_AI_GATEWAY = 32;
QWEN_CODE = 33;
DIFY = 34;
OCA = 35;
MINIMAX = 36;
HICAP = 37;
AIHUBMIX = 38;
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
POOLSIDE = 45;
V0 = 46;
XIAOMI = 47;
ZAI_CODING_PLAN = 49;
reserved 43, 44, 48;
reserved "OPENAI_CODEX_CLI", "OPENCODE", "KILO";
}
enum ApiFormat {
ANTHROPIC_CHAT = 0;
GEMINI_CHAT = 1;
@@ -707,7 +760,7 @@ message ModelsApiConfiguration {
optional string wandb_api_key = 87;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -753,7 +806,7 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
+2 -2
View File
@@ -237,8 +237,8 @@ message Settings {
optional string act_mode_nous_research_model_id = 121;
optional string act_mode_vercel_ai_gateway_model_id = 122;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
optional string plan_mode_api_provider = 124;
optional string act_mode_api_provider = 125;
optional ApiProvider plan_mode_api_provider = 124;
optional ApiProvider act_mode_api_provider = 125;
optional string hicap_model_id = 126;
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
-2
View File
@@ -12,8 +12,6 @@ option java_package = "bot.cline.proto";
service TaskService {
// Cancels the currently running task
rpc cancelTask(EmptyRequest) returns (Empty);
// Cancels a queued prompt by ID
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Clears the current task
+4
View File
@@ -17,6 +17,7 @@ import { getDistinctId } from "./services/logging/distinctId"
import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ClineTempManager } from "./services/temp"
import { cleanupTestMode } from "./services/test/TestMode"
import { ShowMessageType } from "./shared/proto/host/window"
import { syncWorker } from "./shared/services/worker/sync"
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
@@ -170,6 +171,9 @@ export async function tearDown(): Promise<void> {
HookDiscoveryCache.getInstance().dispose()
// Stop periodic temp file cleanup
ClineTempManager.stopPeriodicCleanup()
// Clean up test mode
cleanupTestMode()
} finally {
try {
await StateManager.get().flushPendingState()
@@ -1,16 +1,65 @@
import { type CoreSettingsItem, createCoreSettingsService } from "@cline/core"
import { parseRemoteSkillEntries } from "@core/context/instructions/user-instructions/skills"
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { parseYamlFrontmatter } from "@/core/context/instructions/user-instructions/frontmatter"
import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
import { Controller } from ".."
function coreSkillToSkillInfo(skill: CoreSettingsItem): SkillInfo {
return SkillInfo.create({
name: skill.name,
description: skill.description ?? "",
path: skill.path,
enabled: skill.enabled !== false,
})
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
const skills: SkillInfo[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skillMdPath = path.join(entryPath, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) continue
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
}
const frontmatter = result.data
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
if (!frontmatter.description || typeof frontmatter.description !== "string") continue
if (frontmatter.name !== entryName) continue
skills.push(
SkillInfo.create({
name: entryName,
description: frontmatter.description,
path: skillMdPath,
enabled: true, // Will be updated with toggle state
}),
)
} catch {
// Skip invalid skills
}
}
} catch {
// Directory read error, skip
}
return skills
}
/**
@@ -21,15 +70,33 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
const settingsSnapshot = await createCoreSettingsService().list({
workspaceRoot: primaryWorkspace,
})
const globalSkills = settingsSnapshot.skills
.filter((skill) => skill.source === "global" || skill.source === "global-plugin")
.map(coreSkillToSkillInfo)
const localSkills = settingsSnapshot.skills
.filter((skill) => skill.source === "workspace" || skill.source === "workspace-plugin")
.map(coreSkillToSkillInfo)
const globalSkills: SkillInfo[] = []
const localSkills: SkillInfo[] = []
if (primaryWorkspace) {
const scanDirs = getSkillsDirectoriesForScan(primaryWorkspace)
for (const dir of scanDirs) {
const skills = await scanSkillsDirectory(dir.path)
if (dir.source === "global") {
globalSkills.push(...skills)
} else {
localSkills.push(...skills)
}
}
} else {
const scanDirs = getSkillsDirectoriesForScan("")
for (const dir of scanDirs) {
if (dir.source !== "global") continue
const skills = await scanSkillsDirectory(dir.path)
globalSkills.push(...skills)
}
}
// Get global toggles and apply them
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
for (const skill of globalSkills) {
skill.enabled = globalToggles[skill.path] !== false
}
// Add remote skills from remote config.
// Precedence: remote (enterprise) > disk-global (user) > project (workspace).
@@ -53,6 +120,12 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
)
}
// Get local toggles and apply them
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
for (const skill of localSkills) {
skill.enabled = localToggles[skill.path] !== false
}
return RefreshedSkills.create({
globalSkills,
localSkills,
@@ -1,51 +0,0 @@
import { afterEach, describe, it, mock } from "bun:test"
import * as assert from "assert"
import sinon from "sinon"
import type { Controller } from "../../index"
const installMarketplaceEntryFromCatalogStub: sinon.SinonStub = sinon.stub()
const marketplaceHelpersMock = () => ({
installMarketplaceEntryFromCatalog: installMarketplaceEntryFromCatalogStub,
})
mock.module("../marketplace-helpers", marketplaceHelpersMock)
mock.module("./marketplace-helpers", marketplaceHelpersMock)
describe("installMarketplaceEntry", () => {
afterEach(() => {
installMarketplaceEntryFromCatalogStub.reset()
})
it("reconciles the MCP hub after installing an MCP marketplace entry", async () => {
const { installMarketplaceEntry } = await import("../installMarketplaceEntry")
const reconcileMcpServersFromSettingsRPC = sinon.stub().resolves([])
const invalidateUserInstructionService = sinon.stub().resolves()
const controller = {
mcpHub: { reconcileMcpServersFromSettingsRPC },
invalidateUserInstructionService,
} as unknown as Controller
installMarketplaceEntryFromCatalogStub.resolves({
id: "chrome-devtools",
type: "mcp",
status: "installed",
})
await installMarketplaceEntry(controller, {
entry: {
id: "chrome-devtools",
type: "mcp",
name: "Chrome DevTools",
install: {
args: ["chrome-devtools", "--", "npx", "chrome-devtools-mcp@1.2.0"],
env: [],
},
tags: [],
tagObjects: [],
},
})
assert.equal(installMarketplaceEntryFromCatalogStub.callCount, 1)
assert.equal(reconcileMcpServersFromSettingsRPC.callCount, 1)
assert.equal(invalidateUserInstructionService.callCount, 0)
})
})
@@ -1,20 +1,13 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { installMarketplaceEntryFromCatalog } from "./marketplace-helpers"
import { installMarketplaceEntryWithCli } from "./marketplace-helpers"
export async function installMarketplaceEntry(
controller: Controller,
_controller: Controller,
request: MarketplaceEntryRequest,
): Promise<MarketplaceInstallResult> {
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
const result = await installMarketplaceEntryFromCatalog(request.entry)
if (request.entry.type === "mcp") {
await controller.mcpHub?.reconcileMcpServersFromSettingsRPC()
}
if (request.entry.type === "skill" || request.entry.type === "plugin") {
await controller.invalidateUserInstructionService()
}
return result
return installMarketplaceEntryWithCli(request.entry)
}
@@ -6,25 +6,15 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
import {
disablePluginMcpServersInSettings,
discoverPluginModulePaths,
installMcpServer,
installPlugin,
isMarketplaceSkillInstalled,
type MarketplaceActionResult,
type MarketplaceEntryInput,
type MarketplacePrimitiveType,
parseMcpInstallArgs,
readGlobalSettings,
resolvePluginConfigSearchPaths,
setDisabledPlugin,
syncPluginMcpServersToSettings,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin,
} from "@cline/core"
import { deleteSkillFile } from "@core/controller/file/deleteSkillFile"
import { refreshSkills } from "@core/controller/file/refreshSkills"
import { toggleSkill } from "@core/controller/file/toggleSkill"
import { resolveActiveModelIdFromApiConfiguration } from "@core/controller/models/taskApiModel"
import { DeleteSkillRequest, ToggleSkillRequest } from "@shared/proto/cline/file"
import { ToggleSkillRequest } from "@shared/proto/cline/file"
import {
MarketplaceCatalog,
MarketplaceEntry,
@@ -32,7 +22,6 @@ import {
MarketplaceInstallResult,
MarketplaceLocalInstalledEntries,
MarketplaceLocalInstalledEntry,
MarketplaceLocalInstalledEntryRequest,
ToggleMarketplaceLocalInstalledEntryRequest,
} from "@shared/proto/cline/marketplace"
import { HostProvider } from "@/hosts/host-provider"
@@ -50,6 +39,7 @@ const MARKETPLACE_CATALOG_URL = "https://cline.github.io/marketplace/catalog.jso
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git"
const INSTALL_COMMAND_TIMEOUT_MS = 120_000
const MAX_OUTPUT_CHARS = 12_000
const LOCAL_CLI_ENTRYPOINT_ENV = "CLINE_MARKETPLACE_CLI_PATH"
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i
const SECRET_KEY_VALUE_PATTERN =
@@ -177,9 +167,31 @@ function isOfficialPluginInstalled(entry: MarketplaceEntry): boolean {
return existsSync(installPath)
}
function getSkillCandidates(entry: MarketplaceEntry): string[] {
const candidates = new Set([normalizeMatchValue(entry.id), normalizeMatchValue(entry.name)])
const args = getEntryArgs(entry)
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if ((arg === "--skill" || arg === "-s") && args[index + 1]) {
candidates.add(normalizeMatchValue(args[index + 1]))
index++
continue
}
const skillFilter = arg.split("@").at(1)
if (skillFilter) candidates.add(normalizeMatchValue(skillFilter))
}
candidates.delete("")
return [...candidates]
}
function isSkillInstalled(entry: MarketplaceEntry): boolean {
if (entry.type !== "skill") return false
return isMarketplaceSkillInstalled(toCoreMarketplaceEntry(entry))
return getSkillCandidates(entry).some((candidate) =>
[
join(resolveClineHome(), "skills", candidate, "SKILL.md"),
join(homedir(), ".agents", "skills", candidate, "SKILL.md"),
].some((path) => existsSync(path)),
)
}
export function listInstalledMarketplaceEntries(
@@ -301,37 +313,60 @@ async function runCommand(command: string, args: string[]): Promise<SpawnResult>
})
}
function installMcpMarketplaceEntry(entry: MarketplaceEntry, args: string[]): MarketplaceInstallResult {
const parsed = parseMcpInstallArgs(args)
const result = installMcpServer(parsed)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name || entry.id}.`,
output: result.warnings.join("\n") || undefined,
})
function localCliRunner(): { command: string; args: string[] } | undefined {
const overridePath = process.env[LOCAL_CLI_ENTRYPOINT_ENV]?.trim()
const devWorkspacePath = process.env.DEV_WORKSPACE_FOLDER?.trim()
const candidatePath =
overridePath ||
(devWorkspacePath ? join(devWorkspacePath, "apps", "cli", "src", "index.ts") : undefined) ||
findLocalCliEntrypointFromKnownDirectories()
if (!candidatePath || !existsSync(candidatePath)) return undefined
return {
command: "bun",
args: ["--conditions=development", candidatePath],
}
}
async function installPluginMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
const [source] = args
if (!source) throw new Error("Marketplace plugin install args must start with a plugin source.")
const result = await installPlugin({ source })
const warnings = result.mcpSyncFailures.map(
(failure) => `Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
function findLocalCliEntrypointFromKnownDirectories(): string | undefined {
const startDirectories = [process.cwd(), typeof __dirname === "string" ? __dirname : undefined].filter(
(directory): directory is string => Boolean(directory),
)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name || entry.id}.`,
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
})
for (const startDirectory of startDirectories) {
const candidatePath = findLocalCliEntrypoint(startDirectory)
if (candidatePath) return candidatePath
}
return undefined
}
async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
const command = "npx"
const commandArgs = ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
function findLocalCliEntrypoint(startDirectory: string): string | undefined {
let current = resolve(startDirectory)
for (let depth = 0; depth < 8; depth++) {
const candidatePath = join(current, "apps", "cli", "src", "index.ts")
if (existsSync(candidatePath)) return candidatePath
const parent = dirname(current)
if (parent === current) break
current = parent
}
return undefined
}
export async function installMarketplaceEntryWithCli(entry: MarketplaceEntry): Promise<MarketplaceInstallResult> {
const args = getEntryArgs(entry)
if (args.length === 0) throw new Error("Marketplace install args are required.")
const localCli = entry.type === "mcp" || entry.type === "plugin" ? localCliRunner() : undefined
const command = localCli?.command ?? "npx"
const commandArgs = localCli
? [
...localCli.args,
...(entry.type === "mcp"
? ["mcp", "install", "--yes", "--json", ...args]
: ["plugin", "install", args[0] ?? "", "--json"]),
]
: entry.type === "skill"
? ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
: entry.type === "mcp"
? ["-y", "cline", "mcp", "install", "--yes", "--json", ...args]
: ["-y", "cline", "plugin", "install", args[0] ?? "", "--json"]
const displayCommand = formatCommand(command, commandArgs)
let result: SpawnResult
try {
@@ -360,52 +395,6 @@ async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: strin
})
}
export async function installMarketplaceEntryFromCatalog(entry: MarketplaceEntry): Promise<MarketplaceInstallResult> {
const args = getEntryArgs(entry)
if (args.length === 0) throw new Error("Marketplace install args are required.")
if (entry.type === "mcp") return installMcpMarketplaceEntry(entry, args)
if (entry.type === "plugin") return installPluginMarketplaceEntry(entry, args)
return installSkillMarketplaceEntry(entry, args)
}
function toCoreMarketplaceEntry(entry: MarketplaceEntry): MarketplaceEntryInput {
if (entry.type !== "mcp" && entry.type !== "skill" && entry.type !== "plugin") {
throw new Error(`Unsupported marketplace entry type: ${entry.type}`)
}
return {
id: entry.id,
type: entry.type as MarketplacePrimitiveType,
name: entry.name,
install: {
args: getEntryArgs(entry),
},
}
}
function toProtoMarketplaceInstallResult(result: MarketplaceActionResult): MarketplaceInstallResult {
return MarketplaceInstallResult.create({
id: result.id,
type: result.type,
status: result.status,
message: result.message,
output: result.output,
})
}
export async function uninstallMarketplaceEntryFromCatalog(
controller: Controller,
entry: MarketplaceEntry,
): Promise<MarketplaceInstallResult> {
const workspaceRoot = await getWorkspacePath()
const result = await uninstallCoreMarketplaceEntry(toCoreMarketplaceEntry(entry), {
deleteMcpServer: async (name) => {
await controller.mcpHub?.deleteServerRPC(name)
},
workspaceRoot,
})
return toProtoMarketplaceInstallResult(result)
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }
@@ -471,6 +460,7 @@ export async function listLocalMarketplaceInstalledEntries(controller: Controlle
type: "mcp",
name: server.name,
description: server.status,
path: server.config,
enabled: server.disabled !== true,
}),
)
@@ -553,12 +543,6 @@ export async function toggleLocalMarketplaceInstalledEntry(
): Promise<MarketplaceLocalInstalledEntries> {
const { entry, enabled } = request
if (!entry) throw new Error("Installed marketplace entry is required.")
if (entry.type === "mcp") {
const name = entry.name || entry.id
if (!name) throw new Error("MCP server name is required.")
await controller.mcpHub?.toggleServerDisabledRPC(name, !enabled)
return listLocalMarketplaceInstalledEntries(controller)
}
if (entry.type === "skill") {
await toggleSkill(
controller,
@@ -572,64 +556,7 @@ export async function toggleLocalMarketplaceInstalledEntry(
}
if (entry.type === "plugin") {
await togglePluginLocalEntry(controller, entry, enabled)
await controller.invalidateUserInstructionService()
return listLocalMarketplaceInstalledEntries(controller)
}
throw new Error(`Marketplace toggle is not supported for ${entry.type}.`)
}
export async function uninstallLocalMarketplaceInstalledEntry(
controller: Controller,
request: MarketplaceLocalInstalledEntryRequest,
): Promise<MarketplaceInstallResult> {
const { entry } = request
if (!entry) throw new Error("Installed marketplace entry is required.")
const name = entry.name || entry.id
if (entry.type === "mcp") {
if (!name) throw new Error("MCP server name is required.")
await controller.mcpHub?.deleteServerRPC(name)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
})
}
if (entry.type === "skill") {
if (entry.path?.startsWith("remote:")) {
throw new Error("Remote-managed skills cannot be uninstalled from Customize.")
}
if (!entry.path) throw new Error("Skill path is required for uninstall.")
await deleteSkillFile(
controller,
DeleteSkillRequest.create({
skillPath: entry.path,
isGlobal: entry.source === "global",
}),
)
await controller.invalidateUserInstructionService()
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${name || entry.id}.`,
})
}
if (entry.type === "plugin") {
const workspaceRoot = await getWorkspacePath()
const result = await uninstallPlugin({
name: entry.path ? undefined : name,
path: entry.path,
workspaceRoot,
})
await controller.invalidateUserInstructionService()
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
output: [`Path: ${result.installPath}`, ...result.removedPaths.map((path) => `Removed: ${path}`)].join("\n"),
})
}
throw new Error(`Marketplace uninstall is not supported for ${entry.type}.`)
}
@@ -1,17 +0,0 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { uninstallMarketplaceEntryFromCatalog } from "./marketplace-helpers"
export async function uninstallMarketplaceEntry(
controller: Controller,
request: MarketplaceEntryRequest,
): Promise<MarketplaceInstallResult> {
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
const result = await uninstallMarketplaceEntryFromCatalog(controller, request.entry)
if (request.entry.type === "skill" || request.entry.type === "plugin") {
await controller.invalidateUserInstructionService()
}
return result
}
@@ -1,10 +0,0 @@
import type { MarketplaceInstallResult, MarketplaceLocalInstalledEntryRequest } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { uninstallLocalMarketplaceInstalledEntry } from "./marketplace-helpers"
export async function uninstallMarketplaceLocalInstalledEntry(
controller: Controller,
request: MarketplaceLocalInstalledEntryRequest,
): Promise<MarketplaceInstallResult> {
return uninstallLocalMarketplaceInstalledEntry(controller, request)
}
@@ -1,20 +0,0 @@
import { Empty, type StringRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Cancels a queued prompt for the active SDK session.
*
* @param controller The controller instance
* @param request The request containing the queued prompt ID
* @returns Empty response
*/
export async function cancelQueuedPrompt(controller: Controller, request: StringRequest): Promise<Empty> {
try {
await controller.cancelQueuedPrompt(request.value)
return Empty.create()
} catch (error) {
Logger.error("Error in cancelQueuedPrompt handler:", error)
throw error
}
}
@@ -1,10 +1,21 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import * as actualDiskModule from "@core/storage/disk"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` namespace
// export ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `getMcpSettingsFilePath` via mock.module so the full sinon stub API keeps
// working. Register both the alias form and the relative form the SUT uses.
const getMcpSettingsFilePathStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({ ...actualDiskModule, getMcpSettingsFilePath: getMcpSettingsFilePathStub })
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("../../disk", diskMock)
import { syncRemoteMcpServersToSettings } from "../remote-config/syncRemoteMcpServers"
describe("syncRemoteMcpServersToSettings", () => {
@@ -18,11 +29,20 @@ describe("syncRemoteMcpServersToSettings", () => {
await fs.mkdir(tempDir, { recursive: true })
settingsPath = path.join(tempDir, "cline_mcp_settings.json")
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: {} }, null, 2))
getMcpSettingsFilePathStub.reset()
getMcpSettingsFilePathStub.callsFake(async () => {
try {
await fs.access(settingsPath)
} catch {
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: {} }, null, 2))
}
return settingsPath
})
})
afterEach(async () => {
sandbox.restore()
getMcpSettingsFilePathStub.reset()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch {
@@ -41,7 +61,7 @@ describe("syncRemoteMcpServersToSettings", () => {
describe("adding remote servers", () => {
it("should add a new remote server with remoteConfigured marker", async () => {
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], tempDir)
const result = await readSettings()
result.mcpServers["test-server"].should.have.property("url", "https://example.com/mcp")
@@ -60,7 +80,7 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], tempDir)
const result = await readSettings()
result.mcpServers["test-server"].disabled.should.equal(true)
@@ -68,29 +88,6 @@ describe("syncRemoteMcpServersToSettings", () => {
result.mcpServers["test-server"].remoteConfigured.should.equal(true)
})
it("should preserve nested transport remote server settings when matching remote config", async () => {
await writeSettings({
"test-server": {
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
disabled: true,
autoApprove: ["some-tool"],
remoteConfigured: true,
},
})
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], settingsPath)
const result = await readSettings()
result.mcpServers["test-server"].transport.should.deepEqual({
type: "streamableHttp",
url: "https://example.com/mcp",
})
result.mcpServers["test-server"].url.should.equal("https://example.com/mcp")
result.mcpServers["test-server"].disabled.should.equal(true)
result.mcpServers["test-server"].autoApprove.should.deepEqual(["some-tool"])
result.mcpServers["test-server"].remoteConfigured.should.equal(true)
})
it("should add multiple remote servers", async () => {
await writeSettings({})
@@ -99,7 +96,7 @@ describe("syncRemoteMcpServersToSettings", () => {
{ name: "server-a", url: "https://a.example.com" },
{ name: "server-b", url: "https://b.example.com" },
],
settingsPath,
tempDir,
)
const result = await readSettings()
@@ -117,7 +114,7 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([{ name: "remote-server", url: "https://example.com" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "remote-server", url: "https://example.com" }], tempDir)
const result = await readSettings()
result.mcpServers.should.have.property("local-server")
@@ -136,31 +133,12 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([], settingsPath)
await syncRemoteMcpServersToSettings([], tempDir)
const result = await readSettings()
result.mcpServers.should.not.have.property("old-server")
})
it("should not remove nested transport remote server when URL still matches remote config", async () => {
await writeSettings({
"keep-server": {
transport: { type: "streamableHttp", url: "https://keep.example.com" },
remoteConfigured: true,
disabled: true,
autoApprove: ["tool-a"],
},
})
await syncRemoteMcpServersToSettings([{ name: "keep-server", url: "https://keep.example.com" }], settingsPath)
const result = await readSettings()
result.mcpServers.should.have.property("keep-server")
result.mcpServers["keep-server"].transport.url.should.equal("https://keep.example.com")
result.mcpServers["keep-server"].disabled.should.equal(true)
result.mcpServers["keep-server"].autoApprove.should.deepEqual(["tool-a"])
})
it("should NOT remove a server without remoteConfigured marker", async () => {
await writeSettings({
"user-server": {
@@ -169,7 +147,7 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([], settingsPath)
await syncRemoteMcpServersToSettings([], tempDir)
const result = await readSettings()
result.mcpServers.should.have.property("user-server")
@@ -189,7 +167,7 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([{ name: "keep-server", url: "https://keep.example.com" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "keep-server", url: "https://keep.example.com" }], tempDir)
const result = await readSettings()
result.mcpServers.should.have.property("keep-server")
@@ -203,7 +181,7 @@ describe("syncRemoteMcpServersToSettings", () => {
"local-server": { command: "node", type: "stdio" },
})
await syncRemoteMcpServersToSettings([], settingsPath)
await syncRemoteMcpServersToSettings([], tempDir)
const result = await readSettings()
result.mcpServers.should.not.have.property("server-a")
@@ -222,7 +200,7 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([{ name: "legacy-server", url: "https://legacy.example.com" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "legacy-server", url: "https://legacy.example.com" }], tempDir)
const result = await readSettings()
result.mcpServers["legacy-server"].remoteConfigured.should.equal(true)
@@ -233,7 +211,7 @@ describe("syncRemoteMcpServersToSettings", () => {
it("should handle empty settings file with no mcpServers key", async () => {
await fs.writeFile(settingsPath, JSON.stringify({}, null, 2))
await syncRemoteMcpServersToSettings([{ name: "new-server", url: "https://new.example.com" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "new-server", url: "https://new.example.com" }], tempDir)
const result = await readSettings()
result.mcpServers["new-server"].url.should.equal("https://new.example.com")
@@ -249,7 +227,7 @@ describe("syncRemoteMcpServersToSettings", () => {
},
})
await syncRemoteMcpServersToSettings([{ name: "my-server", url: "https://new-url.example.com" }], settingsPath)
await syncRemoteMcpServersToSettings([{ name: "my-server", url: "https://new-url.example.com" }], tempDir)
const result = await readSettings()
result.mcpServers["my-server"].url.should.equal("https://new-url.example.com")
@@ -263,7 +241,7 @@ describe("syncRemoteMcpServersToSettings", () => {
recordSettingsFingerprint: sandbox.stub(),
}
await syncRemoteMcpServersToSettings([{ name: "test", url: "https://test.com" }], settingsPath, mockMcpHub as any)
await syncRemoteMcpServersToSettings([{ name: "test", url: "https://test.com" }], tempDir, mockMcpHub as any)
mockMcpHub.recordSettingsFingerprint.calledOnce.should.be.true()
const result = await readSettings()
+37 -3
View File
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
import { execa } from "@packages/execa"
import { RemoteConfig } from "@shared/remote-config/schema"
import { GlobalState, Settings } from "@shared/storage/state-keys"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
@@ -8,11 +9,8 @@ import os from "os"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { getDocumentsPath } from "./documents-path"
import { StateManager } from "./StateManager"
export { getDocumentsPath } from "./documents-path"
export { getSkillsDirectoriesForScan, type SkillsScanDirectory } from "./skill-directories"
export const GlobalFileNames = {
@@ -41,6 +39,42 @@ export const GlobalFileNames = {
remoteConfig: (orgId: string) => `remote_config_${orgId}.json`,
}
export async function getDocumentsPath(): Promise<string> {
if (process.platform === "win32") {
try {
const { stdout: docsPath } = await execa("powershell", [
"-NoProfile", // Ignore user's PowerShell profile(s)
"-Command",
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
])
const trimmedPath = docsPath.trim()
if (trimmedPath) {
return trimmedPath
}
} catch (_err) {
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
}
} else if (process.platform === "linux") {
try {
// First check if xdg-user-dir exists
await execa("which", ["xdg-user-dir"])
// If it exists, try to get XDG documents path
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
const trimmedPath = stdout.trim()
if (trimmedPath) {
return trimmedPath
}
} catch {
// Log error but continue to fallback
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
}
}
// Default fallback for all platforms
return path.join(os.homedir(), "Documents")
}
/**
* Returns the cross-platform path to the Cline home directory (~/.cline).
* This works on macOS, Linux, and Windows:
@@ -1,40 +0,0 @@
import { execa } from "@packages/execa"
import os from "os"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
export async function getDocumentsPath(): Promise<string> {
if (process.platform === "win32") {
try {
const { stdout: docsPath } = await execa("powershell", [
"-NoProfile", // Ignore user's PowerShell profile(s)
"-Command",
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
])
const trimmedPath = docsPath.trim()
if (trimmedPath) {
return trimmedPath
}
} catch (_err) {
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
}
} else if (process.platform === "linux") {
try {
// First check if xdg-user-dir exists
await execa("which", ["xdg-user-dir"])
// If it exists, try to get XDG documents path
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
const trimmedPath = stdout.trim()
if (trimmedPath) {
return trimmedPath
}
} catch {
// Log error but continue to fallback
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
}
}
// Default fallback for all platforms
return path.join(os.homedir(), "Documents")
}
@@ -1,23 +1,9 @@
import { getMcpSettingsFilePath } from "@core/storage/disk"
import { RemoteMCPServer } from "@shared/remote-config/schema"
import type { McpHub } from "@/services/mcp/McpHub"
import { updateMcpSettingsFile } from "@/services/mcp/settingsLock"
import { Logger } from "@/shared/services/Logger"
type McpSettingsFingerprintRecorder = {
recordSettingsFingerprint(servers: Record<string, unknown>): void
}
function getConfiguredServerUrl(server: Record<string, unknown>): string | undefined {
if (typeof server.url === "string") {
return server.url
}
const transport = server.transport
if (transport && typeof transport === "object" && !Array.isArray(transport)) {
const url = (transport as Record<string, unknown>).url
return typeof url === "string" ? url : undefined
}
return undefined
}
/**
* Synchronizes remote MCP servers from remote config to the local MCP settings file
* This allows admins to centrally configure MCP servers that are automatically deployed to users
@@ -32,15 +18,18 @@ function getConfiguredServerUrl(server: Record<string, unknown>): string | undef
* - Preventing duplicates when re-adding servers
*
* @param remoteMCPServers Array of remote MCP servers from remote config
* @param settingsPath Path to the MCP settings file
* @param settingsDirectoryPath Path to the settings directory
* @param mcpHub Optional McpHub instance to set flag preventing watcher triggers
*/
export async function syncRemoteMcpServersToSettings(
remoteMCPServers: RemoteMCPServer[],
settingsPath: string,
mcpHub?: McpSettingsFingerprintRecorder,
settingsDirectoryPath: string,
mcpHub?: McpHub,
): Promise<void> {
try {
// Get or create the MCP settings file
const settingsPath = await getMcpSettingsFilePath(settingsDirectoryPath)
// Hold the cross-process lock across the whole read-modify-write so a
// concurrent writer (CLI, another window, an OAuth handshake) cannot drop
// this sync's changes from a stale snapshot. Only writers need the lock;
@@ -55,9 +44,8 @@ export async function syncRemoteMcpServersToSettings(
for (const [serverName, serverConfig] of Object.entries(servers)) {
const server = serverConfig as Record<string, unknown>
if (server.remoteConfigured === true) {
const configuredUrl = getConfiguredServerUrl(server)
const stillInRemoteConfig = remoteMCPServers.some(
(remoteServer) => remoteServer.name === serverName && remoteServer.url === configuredUrl,
(remoteServer) => remoteServer.name === serverName && remoteServer.url === server.url,
)
if (!stillInRemoteConfig) {
delete servers[serverName]
@@ -69,16 +57,10 @@ export async function syncRemoteMcpServersToSettings(
for (const server of remoteMCPServers) {
// Check if server with same name and URL already exists to skip duplicates
const existingServer = servers[server.name]
if (existingServer && getConfiguredServerUrl(existingServer) === server.url) {
if (existingServer && existingServer.url === server.url) {
if (!existingServer.remoteConfigured) {
existingServer.remoteConfigured = true
}
// Keep the historical top-level URL field for remote-configured
// servers so older sync/UI code can identify the managed server
// without needing to understand nested SDK transport shape.
if (!existingServer.url) {
existingServer.url = server.url
}
continue
}
@@ -14,6 +14,7 @@ import { isOpenTelemetryConfigValid, remoteConfigToOtelConfig } from "@/shared/s
import { Logger } from "@/shared/services/Logger"
import { syncWorker } from "@/shared/services/worker/sync"
import { BlobStoreSettings } from "@/shared/storage"
import { ensureSettingsDirectoryExists } from "../disk"
import { StateManager } from "../StateManager"
import { syncRemoteMcpServersToSettings } from "./syncRemoteMcpServers"
@@ -372,7 +373,7 @@ export async function applyRemoteConfig(
// - No dependency on in-memory state that would be lost across restarts
try {
const serversToSync = remoteConfig.remoteMCPServers ?? []
const settingsPath = await mcpHub.getMcpSettingsFilePath()
const settingsPath = await ensureSettingsDirectoryExists()
await syncRemoteMcpServersToSettings(serversToSync, settingsPath, mcpHub)
stateManager.setRemoteConfigField("previousRemoteMCPServers", serversToSync)
} catch (error) {
+5
View File
@@ -14,6 +14,7 @@ import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeTo
import { sendWorktreesButtonClickedEvent } from "./core/controller/ui/subscribeToWorktreesButtonClicked"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { initializeTestMode } from "./services/test/TestMode"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import path from "node:path"
import type { ExtensionContext } from "vscode"
@@ -82,6 +83,10 @@ export async function activate(context: vscode.ExtensionContext) {
const webview = (await initialize(storageContext)) as VscodeWebviewProvider
// 5. Register services and commands specific to VS Code
// Initialize test mode and add disposables to context
const testModeWatchers = await initializeTestMode(webview)
context.subscriptions.push(...testModeWatchers)
// Initialize hook discovery cache for performance optimization
HookDiscoveryCache.getInstance().initialize(
// biome-ignore lint/suspicious/noExplicitAny: Adapt VSCode ExtensionContext to generic interface
@@ -6,7 +6,6 @@ import fs from "fs"
import os from "os"
import path from "path"
import sinon from "sinon"
import { getServerAuthHash } from "@/utils/mcpAuth"
import { exportVSCodeStorageToSharedFiles } from "../vscode-to-file-migration"
/**
@@ -63,9 +62,6 @@ function createMockVSCodeContext() {
},
setKeysForSync() {},
},
globalStorageUri: {
fsPath: "",
},
// Expose internal stores for test setup
_globalStateStore: globalStateStore,
_secretsStore: secretsStore,
@@ -77,14 +73,11 @@ describe("vscode-to-file-migration", () => {
let sandbox: sinon.SinonSandbox
let tempDir: string
let storageContext: StorageContext
let originalMcpSettingsPath: string | undefined
beforeEach(() => {
sandbox = sinon.createSandbox()
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH
tempDir = path.join(os.tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
fs.mkdirSync(tempDir, { recursive: true })
process.env.CLINE_MCP_SETTINGS_PATH = path.join(tempDir, "runtime-mcp-settings", "cline_mcp_settings.json")
storageContext = createStorageContext({
clineDir: tempDir,
@@ -94,11 +87,6 @@ describe("vscode-to-file-migration", () => {
afterEach(() => {
sandbox.restore()
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath
}
try {
fs.rmSync(tempDir, { recursive: true, force: true })
} catch {
@@ -117,41 +105,14 @@ describe("vscode-to-file-migration", () => {
result.globalStateCount.should.be.greaterThan(0)
storageContext.globalState.get("mode")!.should.equal("act")
// Both sentinels should be written
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(2)
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(2)
})
it("should run only MCP settings migration when v1 storage export sentinels are already present", async () => {
storageContext.globalState.update("__vscodeMigrationVersion", 1)
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
mockCtx._workspaceStateStore.set("localClineRulesToggles", { "rule-1": true })
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({ mcpServers: { fromV1: { command: "node" } } }),
)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.globalStateCount.should.equal(0)
result.secretsCount.should.equal(0)
result.workspaceStateCount.should.equal(0)
result.mcpServersAdded.should.equal(1)
;(storageContext.globalState.get("mode") === undefined).should.be.true()
;(storageContext.workspaceState.get("localClineRulesToggles") === undefined).should.be.true()
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(2)
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(2)
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(1)
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(1)
})
it("should skip everything when both sentinels are current version", async () => {
// Pre-set BOTH sentinels
storageContext.globalState.update("__vscodeMigrationVersion", 2)
storageContext.workspaceState.set("__vscodeMigrationVersion", 2)
storageContext.globalState.update("__vscodeMigrationVersion", 1)
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
@@ -162,7 +123,6 @@ describe("vscode-to-file-migration", () => {
result.migrated.should.be.false()
result.globalStateCount.should.equal(0)
result.workspaceStateCount.should.equal(0)
result.mcpServersAdded.should.equal(0)
// Should NOT have the VSCode values — migration was skipped
const modeVal = storageContext.globalState.get("mode")
;(modeVal === undefined).should.be.true()
@@ -178,9 +138,6 @@ describe("vscode-to-file-migration", () => {
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.false()
result.globalStateCount.should.equal(0)
result.workspaceStateCount.should.equal(0)
result.mcpServersAdded.should.equal(0)
})
it("should re-run migration if sentinels are lower version", async () => {
@@ -216,7 +173,7 @@ describe("vscode-to-file-migration", () => {
const stored = storageContext.workspaceState.get("localClineRulesToggles") as any
stored.should.deepEqual({ "rule-1": true })
// Workspace sentinel should now be set
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(2)
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(1)
})
it("should migrate globals when workspace already migrated", async () => {
@@ -237,7 +194,7 @@ describe("vscode-to-file-migration", () => {
// Workspace state should NOT have been migrated
result.workspaceStateCount.should.equal(0)
// Global sentinel should now be set
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(2)
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(1)
})
})
@@ -294,238 +251,6 @@ describe("vscode-to-file-migration", () => {
})
})
describe("legacy MCP settings migration", () => {
function sharedMcpSettingsPath() {
return process.env.CLINE_MCP_SETTINGS_PATH!
}
function readSharedMcpSettings() {
return JSON.parse(fs.readFileSync(sharedMcpSettingsPath(), "utf8"))
}
it("writes to the runtime MCP settings resolver path rather than storage.dataDir", async () => {
const mockCtx = createMockVSCodeContext()
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({ mcpServers: { overrideTarget: { command: "node" } } }),
)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.mcpServersAdded.should.equal(1)
fs.existsSync(sharedMcpSettingsPath()).should.be.true()
fs.existsSync(path.join(storageContext.dataDir, "settings", "cline_mcp_settings.json")).should.be.false()
readSharedMcpSettings().mcpServers.overrideTarget.should.deepEqual({
transport: { type: "stdio", command: "node" },
})
})
it("skips a legacy source that resolves to the shared MCP settings file", async () => {
const mockCtx = createMockVSCodeContext()
const sharedSettingsDir = path.dirname(sharedMcpSettingsPath())
fs.mkdirSync(sharedSettingsDir, { recursive: true })
mockCtx.globalStorageUri.fsPath = path.dirname(sharedSettingsDir)
fs.writeFileSync(sharedMcpSettingsPath(), JSON.stringify({ mcpServers: { alreadyShared: { command: "node" } } }))
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.mcpServersAdded.should.equal(0)
const tombstone = storageContext.globalState.get("__vscodeLegacyMcpSettingsMigration") as any
;(tombstone?.sources?.vscodeGlobalStorage === undefined).should.be.true()
const settings = readSharedMcpSettings()
settings.mcpServers.alreadyShared.should.deepEqual({ command: "node" })
})
it("uses the MCP settings lock when writing migrated servers", async () => {
const mockCtx = createMockVSCodeContext()
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({ mcpServers: { lockedServer: { command: "node" } } }),
)
const sharedSettingsPath = sharedMcpSettingsPath()
const lockDir = `${sharedSettingsPath}.lock`
fs.mkdirSync(lockDir, { recursive: true })
fs.writeFileSync(path.join(lockDir, "owner.test"), "test")
const freshMtime = new Date()
fs.utimesSync(lockDir, freshMtime, freshMtime)
const migration = exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
await new Promise((resolve) => setTimeout(resolve, 75))
;(fs.existsSync(sharedSettingsPath) === false).should.be.true()
fs.rmSync(lockDir, { recursive: true, force: true })
const result = await migration
result.mcpServersAdded.should.equal(1)
const settings = readSharedMcpSettings()
settings.mcpServers.lockedServer.should.deepEqual({ transport: { type: "stdio", command: "node" } })
})
it("merges missing legacy MCP servers from VSCode globalStorage without overwriting shared settings", async () => {
const mockCtx = createMockVSCodeContext()
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({
mcpServers: {
existing: { command: "legacy-existing", args: ["old"] },
stdioLegacy: { command: "node", args: ["server.js"], env: { API_KEY: "abc" } },
},
}),
)
const sharedSettingsDir = path.dirname(sharedMcpSettingsPath())
fs.mkdirSync(sharedSettingsDir, { recursive: true })
fs.writeFileSync(
sharedMcpSettingsPath(),
JSON.stringify({
mcpServers: {
existing: {
transport: { type: "stdio", command: "shared-existing" },
disabled: true,
},
},
}),
)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.mcpServersAdded.should.equal(1)
result.mcpServersSkippedExisting.should.equal(1)
const settings = readSharedMcpSettings()
settings.mcpServers.existing.should.deepEqual({
transport: { type: "stdio", command: "shared-existing" },
disabled: true,
})
settings.mcpServers.stdioLegacy.should.deepEqual({
transport: { type: "stdio", command: "node", args: ["server.js"], env: { API_KEY: "abc" } },
})
})
it("preserves top-level URL for migrated remote-configured URL servers", async () => {
const mockCtx = createMockVSCodeContext()
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({
mcpServers: {
managed: {
url: "https://managed.example.com/mcp",
type: "streamableHttp",
remoteConfigured: true,
disabled: true,
autoApprove: ["tool-a"],
},
},
}),
)
await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
readSharedMcpSettings().mcpServers.managed.should.deepEqual({
transport: { type: "streamableHttp", url: "https://managed.example.com/mcp" },
disabled: true,
autoApprove: ["tool-a"],
remoteConfigured: true,
url: "https://managed.example.com/mcp",
})
})
it("upgrades legacy transportType and OAuth secret format into SDK MCP settings format", async () => {
const mockCtx = createMockVSCodeContext()
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
const serverUrl = "https://linear.example.com/mcp"
const serverHash = getServerAuthHash("linear", serverUrl)
mockCtx._secretsStore.set(
"mcpOAuthSecrets",
JSON.stringify({
[serverHash]: {
tokens: { access_token: "old-token", refresh_token: "refresh" },
tokens_saved_at: 123456,
client_info: { client_id: "client-id" },
code_verifier: "verifier",
redirect_url_at_registration: "http://127.0.0.1:1456/mcp/oauth/callback",
},
}),
)
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({
mcpServers: {
linear: {
transportType: "http",
url: serverUrl,
headers: { Authorization: "Bearer static" },
disabled: false,
timeout: 30,
},
},
}),
)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.mcpServersAdded.should.equal(1)
const settings = readSharedMcpSettings()
settings.mcpServers.linear.should.deepEqual({
transport: {
type: "streamableHttp",
url: serverUrl,
headers: { Authorization: "Bearer static" },
},
disabled: false,
timeout: 30,
oauth: {
clientInformation: { client_id: "client-id" },
tokens: { access_token: "old-token", refresh_token: "refresh" },
codeVerifier: "verifier",
redirectUrl: "http://127.0.0.1:1456/mcp/oauth/callback",
lastAuthenticatedAt: 123456,
},
})
})
it("writes source tombstones and does not re-import a deleted migrated server", async () => {
const mockCtx = createMockVSCodeContext()
const extensionStorage = path.join(tempDir, "vscode-global-storage")
mockCtx.globalStorageUri.fsPath = extensionStorage
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
fs.writeFileSync(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
JSON.stringify({ mcpServers: { oneShot: { command: "node" } } }),
)
await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
const settingsPath = sharedMcpSettingsPath()
fs.writeFileSync(settingsPath, JSON.stringify({ mcpServers: {} }))
const second = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
second.mcpServersAdded.should.equal(0)
const settings = readSharedMcpSettings()
;(settings.mcpServers.oneShot === undefined).should.be.true()
const tombstone = storageContext.globalState.get("__vscodeLegacyMcpSettingsMigration") as any
tombstone.sources.vscodeGlobalStorage.path.should.equal(
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
)
})
})
describe("secrets migration", () => {
it("should migrate secret keys", async () => {
const mockCtx = createMockVSCodeContext()
@@ -1,378 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import os from "node:os"
import path from "node:path"
import { getDocumentsPath } from "@/core/storage/documents-path"
import type * as vscode from "vscode"
import { updateMcpSettingsFile } from "@/services/mcp/settingsLock"
import type { StorageContext } from "@/shared/storage/storage-context"
import { Logger } from "@/shared/services/Logger"
import { getServerAuthHash } from "@/utils/mcpAuth"
import { arePathsEqual } from "@/utils/path"
const MCP_SETTINGS_FILE_NAME = "cline_mcp_settings.json"
const MCP_SETTINGS_MIGRATION_KEY = "__vscodeLegacyMcpSettingsMigration"
type JsonRecord = Record<string, unknown>
export interface LegacyMcpSettingsMigrationResult {
migrated: boolean
sourcesChecked: number
sourcesMigrated: number
serversAdded: number
serversSkippedExisting: number
serversSkippedInvalid: number
}
interface LegacyMcpSource {
id: string
path: string
}
interface PreparedLegacyMcpSource {
source: LegacyMcpSource
servers: Record<string, JsonRecord>
skippedInvalid: number
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readJsonRecord(filePath: string): JsonRecord | undefined {
try {
if (!existsSync(filePath)) {
return undefined
}
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown
return isRecord(parsed) ? parsed : undefined
} catch (error) {
Logger.warn(`[Migration] Failed to read legacy MCP settings from ${filePath}:`, error)
return undefined
}
}
function getServers(settings: JsonRecord | undefined): JsonRecord {
const servers = settings?.mcpServers
return isRecord(servers) ? servers : {}
}
function mapLegacyTransportType(value: unknown): "stdio" | "sse" | "streamableHttp" | undefined {
if (value === "stdio" || value === "sse" || value === "streamableHttp") {
return value
}
if (value === "http") {
return "streamableHttp"
}
return undefined
}
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const strings = value.filter((item): item is string => typeof item === "string")
return strings.length === value.length ? strings : undefined
}
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined
}
const entries = Object.entries(value)
if (!entries.every(([, entryValue]) => typeof entryValue === "string")) {
return undefined
}
return Object.fromEntries(entries) as Record<string, string>
}
function compactRecord(record: JsonRecord): JsonRecord {
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined))
}
function normalizeOauthState(value: unknown): JsonRecord | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized = compactRecord({
clientInformation: isRecord(value.clientInformation) ? value.clientInformation : undefined,
tokens: isRecord(value.tokens) ? value.tokens : undefined,
codeVerifier: typeof value.codeVerifier === "string" ? value.codeVerifier : undefined,
discoveryState: isRecord(value.discoveryState) ? value.discoveryState : undefined,
redirectUrl: typeof value.redirectUrl === "string" ? value.redirectUrl : undefined,
lastError: typeof value.lastError === "string" ? value.lastError : undefined,
lastAuthenticatedAt: typeof value.lastAuthenticatedAt === "number" ? value.lastAuthenticatedAt : undefined,
})
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function normalizeLegacyOAuthSecret(value: unknown): JsonRecord | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized = compactRecord({
clientInformation: isRecord(value.client_info) ? value.client_info : undefined,
tokens: isRecord(value.tokens) ? value.tokens : undefined,
codeVerifier: typeof value.code_verifier === "string" ? value.code_verifier : undefined,
redirectUrl: typeof value.redirect_url_at_registration === "string" ? value.redirect_url_at_registration : undefined,
lastAuthenticatedAt: typeof value.tokens_saved_at === "number" ? value.tokens_saved_at : undefined,
})
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function getUrlForAuthHash(registration: JsonRecord): string | undefined {
const transport = isRecord(registration.transport) ? registration.transport : registration
return typeof transport.url === "string" ? transport.url : undefined
}
export function normalizeLegacyMcpServer(
value: unknown,
legacyOAuthSecrets: JsonRecord | undefined,
serverName: string,
): JsonRecord | undefined {
if (!isRecord(value)) {
return undefined
}
const source = isRecord(value.transport) ? { ...value.transport, ...value } : { ...value }
delete source.transport
const explicitType = mapLegacyTransportType(source.type)
const transportType = mapLegacyTransportType(source.transportType)
const resolvedType = explicitType ?? transportType ?? (typeof source.command === "string" ? "stdio" : undefined)
let transport: JsonRecord | undefined
if (resolvedType === "stdio" && typeof source.command === "string" && source.command.trim()) {
transport = compactRecord({
type: "stdio",
command: source.command,
args: normalizeStringArray(source.args),
cwd: typeof source.cwd === "string" && source.cwd.trim() ? source.cwd : undefined,
env: normalizeStringRecord(source.env),
})
} else {
const urlType = resolvedType ?? "sse"
if ((urlType === "sse" || urlType === "streamableHttp") && typeof source.url === "string") {
transport = compactRecord({
type: urlType,
url: source.url,
headers: normalizeStringRecord(source.headers),
})
}
}
if (!transport) {
return undefined
}
const normalized: JsonRecord = compactRecord({
transport,
disabled: typeof source.disabled === "boolean" ? source.disabled : undefined,
metadata: isRecord(source.metadata) ? source.metadata : undefined,
})
const autoApprove = normalizeStringArray(source.autoApprove)
if (autoApprove) {
normalized.autoApprove = autoApprove
}
if (typeof source.timeout === "number") {
normalized.timeout = source.timeout
}
if (typeof source.remoteConfigured === "boolean") {
normalized.remoteConfigured = source.remoteConfigured
}
// Remote-config sync historically keys URL-based remote servers by a top-level
// `url`. Keep that compatibility field on migrated remote-configured servers
// so the next sync does not delete/recreate them and lose user state.
if (source.remoteConfigured === true && typeof transport.url === "string") {
normalized.url = transport.url
}
const inlineOAuth = normalizeOauthState(source.oauth)
const serverUrl = getUrlForAuthHash(normalized)
const legacyOAuth =
serverUrl && legacyOAuthSecrets
? normalizeLegacyOAuthSecret(legacyOAuthSecrets[getServerAuthHash(serverName, serverUrl)])
: undefined
const oauth = inlineOAuth ?? legacyOAuth
if (oauth) {
normalized.oauth = oauth
}
return normalized
}
function parseLegacyOAuthSecrets(raw: string | undefined): JsonRecord | undefined {
if (!raw) {
return undefined
}
try {
const parsed = JSON.parse(raw) as unknown
return isRecord(parsed) ? parsed : undefined
} catch (error) {
Logger.warn("[Migration] Failed to parse legacy MCP OAuth secrets:", error)
return undefined
}
}
async function readLegacyOAuthSecrets(
vscodeContext: vscode.ExtensionContext,
storage: StorageContext,
): Promise<JsonRecord | undefined> {
let vscodeSecrets: JsonRecord | undefined
try {
vscodeSecrets = parseLegacyOAuthSecrets(await vscodeContext.secrets.get("mcpOAuthSecrets"))
} catch (error) {
Logger.warn("[Migration] Failed to read legacy MCP OAuth secrets from VSCode storage:", error)
}
const fileBackedSecrets = parseLegacyOAuthSecrets(storage.secrets.get("mcpOAuthSecrets"))
if (!vscodeSecrets) {
return fileBackedSecrets
}
if (!fileBackedSecrets) {
return vscodeSecrets
}
return { ...vscodeSecrets, ...fileBackedSecrets }
}
export async function getLegacyMcpSettingsSources(vscodeContext: vscode.ExtensionContext): Promise<LegacyMcpSource[]> {
const sources: LegacyMcpSource[] = []
const extensionStorageDir = vscodeContext.globalStorageUri?.fsPath
if (extensionStorageDir) {
sources.push({
id: "vscodeGlobalStorage",
path: path.join(extensionStorageDir, "settings", MCP_SETTINGS_FILE_NAME),
})
}
const documentsDir = await getDocumentsPath()
sources.push({
id: "documentsClineMcp",
path: path.join(documentsDir, "Cline", "MCP", MCP_SETTINGS_FILE_NAME),
})
return sources
}
export function getSharedMcpSettingsPath(storage: StorageContext): string {
const explicitPath = process.env.CLINE_MCP_SETTINGS_PATH?.trim()
if (explicitPath) {
return explicitPath
}
const explicitDataDir = process.env.CLINE_DATA_DIR?.trim()
if (explicitDataDir) {
return path.join(explicitDataDir, "settings", MCP_SETTINGS_FILE_NAME)
}
const clineDir = process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline")
return path.join(clineDir, "data", "settings", MCP_SETTINGS_FILE_NAME)
}
function readMigrationState(storage: StorageContext): JsonRecord {
const value = storage.globalState.get(MCP_SETTINGS_MIGRATION_KEY)
return isRecord(value) ? value : {}
}
function writeMigrationState(storage: StorageContext, state: JsonRecord): void {
storage.globalState.update(MCP_SETTINGS_MIGRATION_KEY, state)
}
function prepareLegacySource(
source: LegacyMcpSource,
legacyOAuthSecrets: JsonRecord | undefined,
): PreparedLegacyMcpSource | undefined {
if (!existsSync(source.path)) {
return undefined
}
const legacySettings = readJsonRecord(source.path)
if (!legacySettings) {
return undefined
}
const servers: Record<string, JsonRecord> = {}
let skippedInvalid = 0
for (const [serverName, serverValue] of Object.entries(getServers(legacySettings))) {
const normalized = normalizeLegacyMcpServer(serverValue, legacyOAuthSecrets, serverName)
if (!normalized) {
skippedInvalid++
continue
}
servers[serverName] = normalized
}
return { source, servers, skippedInvalid }
}
export async function migrateLegacyMcpSettings(
vscodeContext: vscode.ExtensionContext,
storage: StorageContext,
): Promise<LegacyMcpSettingsMigrationResult> {
const result: LegacyMcpSettingsMigrationResult = {
migrated: false,
sourcesChecked: 0,
sourcesMigrated: 0,
serversAdded: 0,
serversSkippedExisting: 0,
serversSkippedInvalid: 0,
}
const migrationState = readMigrationState(storage)
const migratedSources = isRecord(migrationState.sources) ? migrationState.sources : {}
const sharedSettingsPath = getSharedMcpSettingsPath(storage)
const legacyOAuthSecrets = await readLegacyOAuthSecrets(vscodeContext, storage)
const preparedSources: PreparedLegacyMcpSource[] = []
for (const source of await getLegacyMcpSettingsSources(vscodeContext)) {
result.sourcesChecked++
if (migratedSources[source.id] || arePathsEqual(source.path, sharedSettingsPath)) {
continue
}
const prepared = prepareLegacySource(source, legacyOAuthSecrets)
if (!prepared) {
continue
}
preparedSources.push(prepared)
result.serversSkippedInvalid += prepared.skippedInvalid
}
if (preparedSources.length > 0) {
const mergeResult = await updateMcpSettingsFile(sharedSettingsPath, (settings) => {
const existingServersValue = settings.mcpServers
const servers = isRecord(existingServersValue) ? { ...existingServersValue } : {}
let serversAdded = 0
let serversSkippedExisting = 0
let sourcesMigrated = 0
for (const prepared of preparedSources) {
let sourceAdded = 0
for (const [serverName, serverConfig] of Object.entries(prepared.servers)) {
if (Object.hasOwn(servers, serverName)) {
serversSkippedExisting++
continue
}
servers[serverName] = serverConfig
serversAdded++
sourceAdded++
}
if (sourceAdded > 0) {
sourcesMigrated++
}
}
settings.mcpServers = servers
return { serversAdded, serversSkippedExisting, sourcesMigrated }
})
result.serversAdded += mergeResult.serversAdded
result.serversSkippedExisting += mergeResult.serversSkippedExisting
result.sourcesMigrated += mergeResult.sourcesMigrated
for (const prepared of preparedSources) {
migratedSources[prepared.source.id] = {
path: prepared.source.path,
migratedAt: Date.now(),
}
}
result.migrated = true
}
if (result.migrated) {
writeMigrationState(storage, { sources: migratedSources })
}
return result
}
@@ -1,68 +0,0 @@
import assert from "node:assert/strict"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { VscodeTerminalManager } from "./VscodeTerminalManager"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
function createNeverEndingStream(): AsyncIterable<string> {
return {
async *[Symbol.asyncIterator]() {
await new Promise(() => {})
},
}
}
describe("VscodeTerminalManager", () => {
let sandbox: sinon.SinonSandbox
let manager: VscodeTerminalManager
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true })
manager = new VscodeTerminalManager()
})
afterEach(() => {
manager.disposeAll()
sandbox.restore()
})
it("returns after timing out a reused terminal cwd command", async () => {
const targetCwd = "/tmp/cline-target"
const executeCommandStub = sandbox.stub().returns({
read: () => createNeverEndingStream(),
})
const terminalInfo: TerminalInfo = {
id: 1,
busy: false,
lastCommand: "",
lastActive: Date.now(),
terminal: {
shellIntegration: {
cwd: vscode.Uri.file("/tmp/cline-original"),
executeCommand: executeCommandStub,
},
} as unknown as vscode.Terminal,
}
const getAllTerminalsStub = sandbox.stub(TerminalRegistry, "getAllTerminals").returns([terminalInfo])
let didResolve = false
const terminalPromise = manager.getOrCreateTerminal(targetCwd).then((terminal) => {
didResolve = true
return terminal
})
await sandbox.clock.tickAsync(4999)
assert.equal(didResolve, false)
await sandbox.clock.tickAsync(1)
const terminal = await terminalPromise
assert.equal(terminal, terminalInfo)
assert.equal(terminalInfo.busy, false)
assert.equal(terminalInfo.pendingCwdChange, undefined)
assert.equal(terminalInfo.cwdResolved, undefined)
assert.equal(getAllTerminalsStub.called, true)
assert.equal(executeCommandStub.calledOnceWith(`cd "${targetCwd}"`), true)
})
})
@@ -11,9 +11,6 @@ import { Logger } from "@/shared/services/Logger"
import { mergePromise, VscodeTerminalProcess } from "./VscodeTerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
const CWD_COMMAND_TIMEOUT_MS = 5000
const CWD_STATE_TIMEOUT_MS = 1000
/*
TerminalManager:
- Creates/reuses terminals
@@ -175,57 +172,6 @@ export class VscodeTerminalManager implements ITerminalManager {
return arePathsEqual(currentCwd, targetCwd)
}
private async drainCommandOutput(output: AsyncIterable<string>): Promise<void> {
for await (const _chunk of output) {
// Drain the stream so shell integration can report command completion.
}
}
// VS Code shell integration sometimes finishes the internal `cd` command without
// reporting completion through the execution stream. Timeout this setup step so
// the user's actual command is still sent instead of leaving the chat stuck.
private async runCwdChangeCommand(terminalInfo: TerminalInfo, cwd: string): Promise<boolean> {
const command = `cd "${cwd}"`
const shellIntegration = terminalInfo.terminal.shellIntegration
if (!shellIntegration?.executeCommand) {
terminalInfo.terminal.sendText(command, true)
Logger.warn(
`[TerminalManager] Shell integration executeCommand is unavailable while changing terminal ${terminalInfo.id} cwd. Proceeding after ${CWD_COMMAND_TIMEOUT_MS}ms.`,
)
await new Promise((resolve) => setTimeout(resolve, CWD_COMMAND_TIMEOUT_MS))
return true
}
let timeout: NodeJS.Timeout | undefined
let didTimeOut = false
try {
const execution = shellIntegration.executeCommand(command)
await Promise.race([
this.drainCommandOutput(execution.read()),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
didTimeOut = true
Logger.warn(
`[TerminalManager] Timed out waiting ${CWD_COMMAND_TIMEOUT_MS}ms for terminal ${terminalInfo.id} to run cd "${cwd}". Proceeding with requested command.`,
)
resolve()
}, CWD_COMMAND_TIMEOUT_MS)
}),
])
} catch (error) {
Logger.warn(`[TerminalManager] Failed to observe terminal ${terminalInfo.id} cwd command completion`, error)
return true
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
return didTimeOut
}
runCommand(terminalInfo: ITerminalInfo, command: string): ITerminalProcessResultPromise {
// Cast to VSCode-specific TerminalInfo for internal use
// Using unknown as intermediate cast due to structural differences between ITerminal and vscode.Terminal
@@ -339,34 +285,48 @@ export class VscodeTerminalManager implements ITerminalManager {
(t) => !t.busy && VscodeTerminalManager.effectiveShellPath(t.shellPath) === effectiveExpected,
)
if (availableTerminal) {
availableTerminal.busy = true
// Set up promise and tracking for CWD change
const cwdPromise = new Promise<void>((resolve, reject) => {
availableTerminal.pendingCwdChange = cwd
availableTerminal.cwdResolved = { resolve, reject }
})
try {
const didCwdCommandTimeOut = await this.runCwdChangeCommand(availableTerminal, cwd)
// Open the terminal before the cwd setup command so VS Code has time
// to initialize the terminal surface and shell integration.
availableTerminal.terminal.show()
await new Promise((resolve) => setTimeout(resolve, 2000))
// Add a small delay to ensure terminal is ready after cd
if (!didCwdCommandTimeOut) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Navigate back to the desired directory
// Cast to ITerminalInfo for interface compatibility
const cdProcess = this.runCommand(availableTerminal as unknown as ITerminalInfo, `cd "${cwd}"`)
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
if (availableTerminal.cwdResolved) {
availableTerminal.cwdResolved.resolve()
}
} else if (!didCwdCommandTimeOut) {
await Promise.race([cwdPromise, new Promise((resolve) => setTimeout(resolve, CWD_STATE_TIMEOUT_MS))])
// Wait for the cd command to complete before proceeding
await cdProcess
// Add a small delay to ensure terminal is ready after cd
await new Promise((resolve) => setTimeout(resolve, 100))
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
if (availableTerminal.cwdResolved) {
availableTerminal.cwdResolved.resolve()
}
} finally {
availableTerminal.pendingCwdChange = undefined
availableTerminal.cwdResolved = undefined
availableTerminal.busy = false
} else {
try {
// Wait with a timeout for state change event to resolve
await Promise.race([
cwdPromise,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`CWD timeout: Failed to update to ${cwd}`)), 1000),
),
])
} catch (_err) {
// Clear pending state on timeout
availableTerminal.pendingCwdChange = undefined
availableTerminal.cwdResolved = undefined
}
}
this.terminalIds.add(availableTerminal.id)
// Cast to ITerminalInfo for interface compatibility
@@ -36,16 +36,9 @@ import type * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
import { GlobalStateAndSettingKeys, LocalStateKeys, SecretKeys } from "@/shared/storage/state-keys"
import type { StorageContext } from "@/shared/storage/storage-context"
import { migrateLegacyMcpSettings } from "./mcp-settings-legacy-migration"
/** Version 1 exported VSCode memento/secrets/workspace state to file-backed stores. */
const FILE_BACKED_STORAGE_EXPORT_VERSION = 1
/** Version 2 imports legacy MCP settings files into the shared SDK/CLI settings path. */
const MCP_SETTINGS_MIGRATION_VERSION = 2
/** Bump this when adding new migration steps. */
const CURRENT_MIGRATION_VERSION = MCP_SETTINGS_MIGRATION_VERSION
const CURRENT_MIGRATION_VERSION = 1
/** Sentinel key written to both globalState and workspaceState to track migration independently. */
const MIGRATION_VERSION_KEY = "__vscodeMigrationVersion"
@@ -66,8 +59,6 @@ export interface MigrationResult {
secretsCount: number
workspaceStateCount: number
skippedExisting: number
mcpServersAdded: number
mcpServersSkippedExisting: number
}
/**
@@ -94,19 +85,16 @@ export async function exportVSCodeStorageToSharedFiles(
secretsCount: 0,
workspaceStateCount: 0,
skippedExisting: 0,
mcpServersAdded: 0,
mcpServersSkippedExisting: 0,
}
// Check sentinels independently
const globalVersion = storage.globalState.get<number>(MIGRATION_VERSION_KEY)
const workspaceVersion = storage.workspaceState.get<number>(MIGRATION_VERSION_KEY)
const needGlobalMigration = globalVersion === undefined || globalVersion < FILE_BACKED_STORAGE_EXPORT_VERSION
const needWorkspaceMigration = workspaceVersion === undefined || workspaceVersion < FILE_BACKED_STORAGE_EXPORT_VERSION
const needMcpSettingsMigration = globalVersion === undefined || globalVersion < MCP_SETTINGS_MIGRATION_VERSION
const needGlobalMigration = globalVersion === undefined || globalVersion < CURRENT_MIGRATION_VERSION
const needWorkspaceMigration = workspaceVersion === undefined || workspaceVersion < CURRENT_MIGRATION_VERSION
if (!needGlobalMigration && !needWorkspaceMigration && !needMcpSettingsMigration) {
if (!needGlobalMigration && !needWorkspaceMigration) {
Logger.info(
`[Migration] File-backed stores already current (global: v${globalVersion}, workspace: v${workspaceVersion}), skipping.`,
)
@@ -118,13 +106,6 @@ export async function exportVSCodeStorageToSharedFiles(
)
try {
// ─── 0. Migrate legacy MCP settings files (if needed) ───────────
if (needMcpSettingsMigration) {
const mcpMigration = await migrateLegacyMcpSettings(vscodeContext, storage)
result.mcpServersAdded = mcpMigration.serversAdded
result.mcpServersSkippedExisting = mcpMigration.serversSkippedExisting
}
// ─── 1. Migrate global state + secrets (if needed) ─────────────
if (needGlobalMigration) {
// Batch global state keys
@@ -149,8 +130,7 @@ export async function exportVSCodeStorageToSharedFiles(
result.globalStateCount++
}
// Add sentinel to batch. This advances straight to CURRENT because the
// v2 MCP migration already ran above when needed.
// Add sentinel to batch
globalStateBatch[MIGRATION_VERSION_KEY] = CURRENT_MIGRATION_VERSION
// Write all global state in one operation
@@ -202,32 +182,19 @@ export async function exportVSCodeStorageToSharedFiles(
result.workspaceStateCount++
}
// Add sentinel to batch. This advances straight to CURRENT because any
// global v2-only migrations already ran above when needed.
// Add sentinel to batch
workspaceStateBatch[MIGRATION_VERSION_KEY] = CURRENT_MIGRATION_VERSION
// Write all workspace state in one operation
storage.workspaceState.setBatch(workspaceStateBatch)
}
// If the original v1 export was already complete, still advance sentinels
// for this workspace after the v2 MCP migration attempt so future startups
// don't re-run it. New workspaces still have no workspace sentinel and will
// get their v1 workspace-state export when first opened.
if (!needGlobalMigration && needMcpSettingsMigration) {
storage.globalState.update(MIGRATION_VERSION_KEY, CURRENT_MIGRATION_VERSION)
}
if (!needWorkspaceMigration && needMcpSettingsMigration) {
storage.workspaceState.set(MIGRATION_VERSION_KEY, CURRENT_MIGRATION_VERSION)
}
result.migrated = needGlobalMigration || needWorkspaceMigration || needMcpSettingsMigration || result.mcpServersAdded > 0
result.migrated = true
Logger.info(
`[Migration] Complete: ${result.globalStateCount} global state keys, ` +
`${result.secretsCount} secrets, ${result.workspaceStateCount} workspace state keys migrated. ` +
`${result.skippedExisting} keys skipped (already in file store). ` +
`Legacy MCP migration added ${result.mcpServersAdded} server(s), skipped ${result.mcpServersSkippedExisting} existing server(s).`,
`${result.skippedExisting} keys skipped (already in file store).`,
)
} catch (error) {
Logger.error("[Migration] Fatal error during VSCode → file-backed migration:", error)
+10 -43
View File
@@ -5,12 +5,12 @@
// cancelTask, …) to the Cline SDK (@cline/core) and bridges SDK events to
// the webview's gRPC streams.
import * as fs from "node:fs/promises"
import * as os from "node:os"
import * as path from "node:path"
import {
createUserInstructionConfigService,
getProviderAuthStorageId,
type PreparedRemoteConfigCoreIntegration,
resolveDefaultMcpSettingsPath,
type SessionHistoryRecord,
setTelemetryOptOutGlobally,
type UserInstructionConfigService,
@@ -232,7 +232,8 @@ export class Controller {
this.mcpHub = new McpHub(
() => ensureMcpServersDirectoryExists(),
async () => {
const settingsDir = path.dirname(resolveDefaultMcpSettingsPath())
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const settingsDir = path.join(clineDir, "data", "settings")
await fs.mkdir(settingsDir, { recursive: true })
return settingsDir
},
@@ -610,15 +611,6 @@ export class Controller {
}
}
async invalidateUserInstructionService(): Promise<void> {
const userInstructionServicePromise = this.userInstructionService
this.userInstructionService = undefined
this.userInstructionServiceRoot = undefined
if (userInstructionServicePromise) {
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
}
}
async dispose(): Promise<void> {
this.providerConfigStoreSubscription.dispose()
// Clear the remote config timer to prevent stale fetches
@@ -628,7 +620,11 @@ export class Controller {
}
await this.setRemoteConfigCoreIntegration(undefined)
this.isDisposed = true
await this.invalidateUserInstructionService()
const userInstructionServicePromise = this.userInstructionService
this.userInstructionService = undefined
if (userInstructionServicePromise) {
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
}
this.messages.cancelPendingSave()
// Clear MCP tool list change callback before disposing McpHub
this.mcpHub?.clearToolListChangeCallback()
@@ -671,11 +667,7 @@ export class Controller {
this.userInstructionService = (async () => {
const service = createUserInstructionConfigService({
workflows: { workspacePath: workspaceRoot },
skills: {
workspacePath: workspaceRoot,
includePluginSkills: true,
cwd: workspaceRoot,
},
skills: { workspacePath: workspaceRoot },
rules: { workspacePath: workspaceRoot },
})
// start() runs the initial scan; await so the snapshot is populated
@@ -997,29 +989,6 @@ export class Controller {
stubWarn("cancelBackgroundCommand")
}
async cancelQueuedPrompt(promptId: string): Promise<void> {
const trimmedPromptId = promptId.trim()
if (!trimmedPromptId) {
Logger.warn("[SdkController] cancelQueuedPrompt: Missing prompt id")
return
}
const activeSession = this.sessions.getActiveSession()
if (!activeSession) {
Logger.warn("[SdkController] cancelQueuedPrompt: No active session")
return
}
const result = await activeSession.sdkHost.pendingPrompts("delete", {
sessionId: activeSession.sessionId,
promptId: trimmedPromptId,
})
if (!result.removed) {
Logger.warn(`[SdkController] cancelQueuedPrompt: Prompt not found: ${trimmedPromptId}`)
}
await this.postStateToWebview()
}
/**
* Manually compact (condense) the active task's conversation. Triggered by
* the compact button and the `/compact` (alias `/smol`) slash command.
@@ -1060,8 +1029,6 @@ export class Controller {
return
}
const turnStateBefore = this.turnStateTracker.get()
// Answering an ask / continuing after completion / resuming a cancelled task all kick off a
// new agent turn — move the authoritative phase to "streaming" so the footer shows
// Thinking + Cancel (and not the stale resumable/completed/awaiting_followup buttons or the
@@ -1071,7 +1038,7 @@ export class Controller {
this.turnStateTracker.set("streaming")
// Clear the previous turn's completion signal so this new turn's phase is computed fresh.
this.messageTranslatorState.clearTurnOutcome()
await this.followups.askResponse(prompt, images, files, this.task?.taskState?.askResponse, turnStateBefore.phase)
await this.followups.askResponse(prompt, images, files, this.task?.taskState?.askResponse)
}
async editMessageAndRegenerate(input: {
@@ -333,21 +333,6 @@ describe("buildSessionConfig", () => {
expect(mocks.providerSettingsManager.getProviderSettings).not.toHaveBeenCalled()
})
it("resolves OpenAI Compatible API keys from migrated SDK provider settings", () => {
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
} as any
})
expect(resolveApiKey("openai", {} as any)).toBe("migrated-openai-compatible-key")
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("openai-compatible")
})
it("resolves OpenAI Codex through the shared OAuth provider registry", async () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue({
provider: "openai-codex",
+5 -8
View File
@@ -133,10 +133,6 @@ function hasStaleDisabledReasoningFields(reasoning: ProviderReasoningSettings |
return reasoning?.enabled === false && (reasoning.effort !== undefined || reasoning.budgetTokens !== undefined)
}
function providerSettingsProviderId(providerId: string): string {
return toSdkProviderId(providerId)
}
/**
* Convert SDK provider-level reasoning settings into the SDK session fields that
* are actually forwarded as model options. Keep `thinking` and
@@ -164,7 +160,7 @@ export function normalizeProviderReasoningSettings(reasoning: ProviderReasoningS
function resolveProviderReasoningConfig(providerId: string): SessionReasoningConfig {
try {
const manager = getProviderSettingsManager(resolveDataDir())
const settings = manager.getProviderSettings(providerSettingsProviderId(providerId))
const settings = manager.getProviderSettings(providerId)
if (!settings) {
return {}
}
@@ -337,7 +333,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
// hardcoding provider exceptions.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
if (apiKey) {
return apiKey
}
@@ -363,7 +359,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
// startup.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
if (apiKey) {
return apiKey
}
@@ -652,6 +648,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
}
const stateManager = StateManager.get()
const globalSubagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled") ?? false
const globalUseAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense") ?? false
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting") ?? true
const useAutoCondense = input.taskSettings?.useAutoCondense ?? globalUseAutoCondense
@@ -691,7 +688,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
checkpoint: {
enabled: enableCheckpoints,
},
enableSpawnAgent: false,
enableSpawnAgent: input.taskSettings?.subagentsEnabled ?? globalSubagentsEnabled,
enableAgentTeams: false,
...(useAutoCondense
? {
@@ -165,41 +165,6 @@ describe("translateSessionEvent — chunk events", () => {
})
})
// ---------------------------------------------------------------------------
// translateSessionEvent — pending prompts
// ---------------------------------------------------------------------------
describe("translateSessionEvent — pending prompts", () => {
it("renders a submitted queued prompt as user feedback", () => {
const state = new MessageTranslatorState()
const event: CoreSessionEvent = {
type: "pending_prompt_submitted",
payload: {
sessionId: "session-1",
id: "pending-1",
prompt: "please just finish",
delivery: "queue",
attachmentCount: 2,
userImages: ["image.png"],
userFiles: ["notes.txt"],
},
}
const result = translateSessionEvent(event, state)
expect(result.messages).toEqual([
expect.objectContaining({
type: "say",
say: "user_feedback",
text: "please just finish",
images: ["image.png"],
files: ["notes.txt"],
partial: false,
}),
])
})
})
// ---------------------------------------------------------------------------
// translateSessionEvent — agent_event (content_start)
// ---------------------------------------------------------------------------
+2 -20
View File
@@ -1597,27 +1597,9 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra
break
}
case "pending_prompt_submitted": {
const { prompt, userImages, userFiles } = event.payload
const hasPrompt = prompt.trim().length > 0
const hasImages = (userImages?.length ?? 0) > 0
const hasFiles = (userFiles?.length ?? 0) > 0
if (hasPrompt || hasImages || hasFiles) {
result.messages.push({
ts: state.nextTs(),
type: "say",
say: "user_feedback",
text: prompt,
images: userImages,
files: userFiles,
partial: false,
})
}
break
}
case "team_progress":
case "pending_prompts": {
case "pending_prompts":
case "pending_prompt_submitted": {
// These are handled by the team/subagent system, not translated
// to ClineMessages at this layer
break
@@ -94,28 +94,9 @@ describe("buildEffectiveProviderConfig", () => {
})
})
it("reads migrated OpenAI Compatible settings from the SDK provider id", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
},
})
expect(buildEffectiveProviderConfig(parseProviderId("openai"))).toEqual({
providerId: parseProviderId("openai"),
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
})
})
it("reads normalized nousResearch API key from StateManager", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({ nousResearch: { provider: "nousResearch", apiKey: "provider-nous-key" } })
mocks.setProviderSettings({ nousresearch: { provider: "nousresearch", apiKey: "provider-nous-key" } })
mocks.setApiConfiguration({ nousResearchApiKey: "state-nous-key" })
expect(buildEffectiveProviderConfig(parseProviderId("nousResearch"))).toEqual({
@@ -2,7 +2,6 @@ import type { ApiConfiguration } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { AwsProviderConfig, EffectiveProviderConfig, GcpProviderConfig, ProviderId } from "./contracts"
import { toSdkProviderId } from "./sdk-provider-id"
type AuthConfig = NonNullable<EffectiveProviderConfig["auth"]>
type ExtrasConfig = NonNullable<EffectiveProviderConfig["extras"]>
@@ -193,7 +192,7 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
function readProviderSettings(providerId: ProviderId): ConfigParts {
try {
const settings: unknown = getProviderSettingsManager().getProviderSettings(toSdkProviderId(providerId))
const settings: unknown = getProviderSettingsManager().getProviderSettings(providerId)
if (!isPlainRecord(settings)) {
return {}
}
+2 -118
View File
@@ -181,129 +181,13 @@ describe("createProviderConfigStore", () => {
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
expect(mocks.getSavedProviderSettings("nousresearch")).toMatchObject({
provider: "nousresearch",
apiKey: "nous-key",
model: "nousresearch/hermes-4-70b",
})
})
it("reads migrated OpenAI Compatible settings from the SDK provider id", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
expect(store.read(providerId)).toEqual({
providerId,
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
})
})
it("writes OpenAI Compatible settings under the SDK provider id", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.write(providerId, {
apiKey: "openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
})
})
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
store.commitSelection(providerId, "act", selection)
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "gpt-oss-120b",
})
})
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "plan-openai-model",
planModeOpenAiModelInfo: modelInfoA,
actModeOpenAiModelId: "act-openai-model",
actModeOpenAiModelInfo: modelInfoB,
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "act-openai-model",
})
})
it("mirrors OpenAI Compatible selections to both modes when separate models are disabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "plan")).toEqual(selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "shared-openai-model",
planModeOpenAiModelInfo: modelInfoA,
actModeOpenAiModelId: "shared-openai-model",
actModeOpenAiModelInfo: modelInfoA,
})
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
model: "shared-openai-model",
})
})
it("writes Z.AI Coding Plan API keys only to provider-specific settings", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ zaiApiKey: "shared-zai-key" })
+2 -7
View File
@@ -127,10 +127,6 @@ function providerForStorage(providerId: ProviderId): ApiProvider | undefined {
return key as ApiProvider
}
function providerSettingsProviderId(providerId: ProviderId): string {
return toSdkProviderId(providerId)
}
function memoryKey(providerId: ProviderId, mode: Mode): string {
return `${providerId}:${mode}`
}
@@ -284,13 +280,12 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
const settings = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))
const settings = getProviderSettingsManager().getProviderSettings(providerId)
return isRecord(settings) ? settings : {}
}
function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsRecord): void {
const provider = providerSettingsProviderId(providerId)
getProviderSettingsManager().saveProviderSettings({ ...next, provider }, { setLastUsed: false })
getProviderSettingsManager().saveProviderSettings({ provider: providerId, ...next }, { setLastUsed: false })
}
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
@@ -21,12 +21,7 @@ describe("SdkFollowupCoordinator", () => {
await coordinator.askResponse("yes", undefined, undefined, "yesButtonClicked")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"yes",
"yesButtonClicked",
undefined,
undefined,
)
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("yes", "yesButtonClicked")
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
})
@@ -77,7 +72,6 @@ describe("SdkFollowupCoordinator", () => {
await coordinator.askResponse("queued")
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.resetMessageTranslator).not.toHaveBeenCalled()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
@@ -89,87 +83,6 @@ describe("SdkFollowupCoordinator", () => {
)
})
it("queues a follow-up when the turn phase is still streaming even if the session running flag is stale", async () => {
const activeSession = makeActiveSession({ isRunning: false })
const task = makeTask("session-123")
const { coordinator, options } = makeCoordinator({ activeSession, task })
await coordinator.askResponse("queued while streaming", undefined, undefined, "messageResponse", "streaming")
expect(options.waitForPendingModeRebuild).not.toHaveBeenCalled()
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.resetMessageTranslator).not.toHaveBeenCalled()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
"session-123",
"resolved: queued while streaming",
undefined,
undefined,
"queue",
)
})
it("queues a chat-field message submitted while a tool approval is pending", async () => {
const activeSession = makeActiveSession({ isRunning: false })
const task = makeTask("session-123")
const { coordinator, options } = makeCoordinator({ activeSession, task })
options.interactions.resolvePendingToolApproval.mockReturnValue(false)
await coordinator.askResponse(
"do the next thing after this",
undefined,
undefined,
"messageResponse",
"awaiting_approval",
)
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"do the next thing after this",
"messageResponse",
undefined,
undefined,
)
expect(options.waitForPendingModeRebuild).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.resetMessageTranslator).not.toHaveBeenCalled()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
"session-123",
"resolved: do the next thing after this",
undefined,
undefined,
"queue",
)
})
it("sends immediately when the submit-time phase was completed", async () => {
const activeSession = makeActiveSession({ isRunning: false })
const { coordinator, options } = makeCoordinator({ activeSession })
await coordinator.askResponse("next request", undefined, undefined, "messageResponse", "completed")
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[
expect.objectContaining({
type: "say",
say: "user_feedback",
text: "next request",
}),
],
{ type: "status", payload: { sessionId: "session-123", status: "running" } },
)
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
"session-123",
"resolved: next request",
undefined,
undefined,
undefined,
)
})
it("waits for an in-flight mode rebuild before deciding whether to resume a displayed task", async () => {
const task = makeTask("task-1")
const rebuiltSession = makeActiveSession({ isRunning: true })
@@ -204,20 +117,24 @@ describe("SdkFollowupCoordinator", () => {
)
})
it("queues a message response after a pending tool approval is not resolved by chat text", async () => {
it("queues a message response after a pending tool approval is rejected", async () => {
const activeSession = makeActiveSession({ isRunning: true })
const { coordinator, options } = makeCoordinator({ activeSession })
options.interactions.resolvePendingToolApproval.mockReturnValue(false)
await coordinator.askResponse("just give me an answer", undefined, undefined, "messageResponse")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"just give me an answer",
"messageResponse",
undefined,
undefined,
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("just give me an answer", "messageResponse")
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[
expect.objectContaining({
type: "say",
say: "user_feedback",
text: "just give me an answer",
}),
],
{ type: "status", payload: { sessionId: "session-123", status: "running" } },
)
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
"session-123",
+10 -20
View File
@@ -1,5 +1,5 @@
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
import type { ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { Mode } from "@shared/storage/types"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import type { StateManager } from "@/core/storage/StateManager"
@@ -47,18 +47,12 @@ export interface SdkFollowupCoordinatorOptions {
export class SdkFollowupCoordinator {
constructor(private readonly options: SdkFollowupCoordinatorOptions) {}
async askResponse(
prompt?: string,
images?: string[],
files?: string[],
askResponse?: ClineAskResponse,
turnPhaseAtSubmit?: TurnPhase,
): Promise<void> {
async askResponse(prompt?: string, images?: string[], files?: string[], askResponse?: ClineAskResponse): Promise<void> {
if (this.options.interactions.resolvePendingMistakeLimit(prompt, askResponse)) {
return
}
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse, images, files)) {
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse)) {
return
}
@@ -68,9 +62,7 @@ export class SdkFollowupCoordinator {
let activeSession = this.options.sessions.getActiveSession()
const task = this.options.getTask()
const submittedDuringActiveTurn = turnPhaseAtSubmit === "streaming" || turnPhaseAtSubmit === "awaiting_approval"
const isActiveTurnInProgress = () => !!activeSession && (activeSession.isRunning || submittedDuringActiveTurn)
if (!isActiveTurnInProgress() && task) {
if (!activeSession?.isRunning && task) {
// A mode rebuild clears the active session while the old stop is
// awaited and only marks the replacement running after the
// continuation send. Resuming in that window would start a parallel
@@ -79,7 +71,7 @@ export class SdkFollowupCoordinator {
await this.options.waitForPendingModeRebuild()
activeSession = this.options.sessions.getActiveSession()
}
if (!isActiveTurnInProgress() && task) {
if (!activeSession?.isRunning && task) {
Logger.log(`[SdkController] askResponse: No active session but task exists (${task.taskId}), resuming...`)
await this.tryResumeSessionFromTask(task.taskId, prompt, images, files)
return
@@ -91,19 +83,17 @@ export class SdkFollowupCoordinator {
}
const { sdkHost, sessionId } = activeSession
const shouldQueue = isActiveTurnInProgress()
const delivery = shouldQueue ? ("queue" as const) : undefined
const wasAlreadyRunning = activeSession.isRunning
const delivery = wasAlreadyRunning ? ("queue" as const) : undefined
if (shouldQueue) {
if (wasAlreadyRunning) {
Logger.log(`[SdkController] Session is running - queuing follow-up message for session: ${sessionId}`)
}
this.options.sessions.setRunning(true)
if (!shouldQueue) {
this.emitUserFeedback(sessionId, prompt, images, files)
}
this.emitUserFeedback(sessionId, prompt, images, files)
if (!shouldQueue) {
if (!wasAlreadyRunning) {
this.options.resetMessageTranslator()
}
@@ -4,7 +4,7 @@ import { MessageTranslatorState, translateSessionEvent } from "./message-transla
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
import { SdkMessageCoordinator } from "./sdk-message-coordinator"
import { createTaskProxy } from "./task-proxy"
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON, USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
vi.mock("./webview-grpc-bridge", () => ({
pushMessageToWebview: vi.fn().mockResolvedValue(undefined),
@@ -103,9 +103,8 @@ describe("SdkInteractionCoordinator", () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const recordApprovedToolMessage = vi.fn()
const recordDeniedToolApproval = vi.fn()
const messages = new SdkMessageCoordinator({ getTask: () => task })
const coordinator = new SdkInteractionCoordinator({
messages,
messages: new SdkMessageCoordinator({ getTask: () => task }),
getSessionId: () => "session-123",
postStateToWebview: vi.fn().mockResolvedValue(undefined),
recordApprovedToolMessage,
@@ -126,21 +125,13 @@ describe("SdkInteractionCoordinator", () => {
const clineMessages = task.messageStateHandler.getClineMessages()
expect(clineMessages[0]).toMatchObject({ type: "ask", ask: "command", text: "npm test" })
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked", ["image.png"], ["a.ts"])).toBe(true)
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked")).toBe(true)
expect(recordApprovedToolMessage).not.toHaveBeenCalled()
expect(recordDeniedToolApproval).toHaveBeenCalledWith("tool-call", "execute_command", "too risky")
expect(task.messageStateHandler.getClineMessages()[1]).toMatchObject({
type: "say",
say: "user_feedback",
text: "too risky",
images: ["image.png"],
files: ["a.ts"],
partial: false,
})
await expect(approvalPromise).resolves.toEqual({ approved: false, reason: "too risky" })
})
it("routes message responses as queued follow-ups without resolving pending tool approval", async () => {
it("routes message responses as follow-ups instead of tool denial text", async () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const setTurnPhase = vi.fn()
const recordDeniedToolApproval = vi.fn()
@@ -164,11 +155,16 @@ describe("SdkInteractionCoordinator", () => {
await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1))
expect(coordinator.resolvePendingToolApproval("just give me an answer", "messageResponse")).toBe(false)
expect(recordDeniedToolApproval).not.toHaveBeenCalled()
expect(setTurnPhase).toHaveBeenLastCalledWith("awaiting_approval", task.messageStateHandler.getClineMessages()[0].ts)
expect(coordinator.resolvePendingToolApproval(undefined, "yesButtonClicked")).toBe(true)
await expect(approvalPromise).resolves.toEqual({ approved: true })
await expect(approvalPromise).resolves.toEqual({
approved: false,
reason: USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON,
})
expect(recordDeniedToolApproval).toHaveBeenCalledWith(
"tool-call",
"fetch_web_content",
USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON,
)
expect(setTurnPhase).toHaveBeenLastCalledWith("streaming")
})
it("records generic no-button approval denials for UI suppression", async () => {
@@ -197,7 +193,6 @@ describe("SdkInteractionCoordinator", () => {
approved: false,
reason: DEFAULT_TOOL_APPROVAL_DENIAL_REASON,
})
expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)
expect(recordDeniedToolApproval).toHaveBeenCalledWith(
"tool-call",
"fetch_web_content",
@@ -5,7 +5,7 @@ import { Logger } from "@/shared/services/Logger"
import { MessageIdMinter } from "./message-id-minter"
import { buildToolApprovalAskMessage } from "./message-translator"
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON, USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
export interface ToolApprovalRequest {
agentId: string
@@ -127,29 +127,31 @@ export class SdkInteractionCoordinator {
})
}
resolvePendingToolApproval(
prompt: string | undefined,
responseType: ClineAskResponse | undefined,
images?: string[],
files?: string[],
): boolean {
resolvePendingToolApproval(prompt: string | undefined, responseType: ClineAskResponse | undefined): boolean {
if (!this.pendingToolApprovalResolve) {
return false
}
const resolve = this.pendingToolApprovalResolve
const pendingMessage = this.pendingToolApprovalMessage
if (responseType === "messageResponse") {
Logger.log("[SdkController] Leaving pending tool approval open and routing user message as queued follow-up")
this.options.setTurnPhase?.("awaiting_approval", pendingMessage?.messageTs)
// The approval remains pending. The chat message still needs normal follow-up routing.
return false
}
this.pendingToolApprovalResolve = undefined
this.pendingToolApprovalMessage = undefined
if (responseType === "messageResponse") {
Logger.log("[SdkController] Rejecting pending tool approval from user message and routing message as follow-up")
this.options.setTurnPhase?.("streaming")
if (pendingMessage) {
this.options.recordDeniedToolApproval?.(
pendingMessage.toolCallId,
pendingMessage.toolName,
USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON,
)
}
resolve({ approved: false, reason: USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON })
// The approval was resolved, but the chat message still needs normal follow-up routing.
return false
}
const approved = responseType === "yesButtonClicked"
Logger.log(`[SdkController] Resolving pending tool approval: approved=${approved} (responseType=${responseType})`)
if (approved && pendingMessage) {
@@ -160,21 +162,6 @@ export class SdkInteractionCoordinator {
// On rejection the agent receives the denial and continues; the SDK drives the next phase.
this.options.setTurnPhase?.("streaming")
const denialReason = prompt || DEFAULT_TOOL_APPROVAL_DENIAL_REASON
if (!approved && (prompt?.trim() || images?.length || files?.length)) {
const userMessage: ClineMessage = {
ts: this.nextMessageTs(),
type: "say",
say: "user_feedback",
text: prompt ?? "",
images,
files,
partial: false,
}
this.options.messages.appendAndEmit([userMessage], {
type: "status",
payload: { sessionId: this.options.getSessionId(), status: "running" },
})
}
if (!approved && pendingMessage) {
this.options.recordDeniedToolApproval?.(pendingMessage.toolCallId, pendingMessage.toolName, denialReason)
}
@@ -111,62 +111,6 @@ describe("SdkSessionEventCoordinator", () => {
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
it("marks a submitted queued prompt as a new streaming turn", async () => {
const message: ClineMessage = { ts: 1, type: "say", say: "user_feedback", text: "queued prompt" }
const { coordinator, options } = makeCoordinator({
translation: {
messages: [message],
sessionEnded: false,
turnComplete: false,
},
})
const clearTurnOutcome = vi.spyOn(options.messageTranslatorState, "clearTurnOutcome")
const event: CoreSessionEvent = {
type: "pending_prompt_submitted",
payload: {
sessionId: "session-123",
id: "pending-1",
prompt: "queued prompt",
delivery: "queue",
attachmentCount: 0,
},
} as CoreSessionEvent
await coordinator.handleSessionEvent(event)
expect(clearTurnOutcome).toHaveBeenCalledOnce()
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
it("posts state for queued prompt turn start even when no transcript message is emitted", async () => {
const { coordinator, options } = makeCoordinator({
translation: {
messages: [],
sessionEnded: false,
turnComplete: false,
},
})
const event: CoreSessionEvent = {
type: "pending_prompt_submitted",
payload: {
sessionId: "session-123",
id: "pending-1",
prompt: "",
delivery: "queue",
attachmentCount: 0,
},
} as CoreSessionEvent
await coordinator.handleSessionEvent(event)
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
it("does NOT override the phase on a turn-complete straggler from an already-cancelled session", async () => {
// After cancelTask sets phase "resumable" and aborts, the SDK may still emit a trailing
// done/turnComplete. Because the session is no longer running, this straggler must NOT
@@ -318,7 +262,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
taskHistory: SdkSessionEventCoordinatorOptions["taskHistory"] & { updateTaskUsage: ReturnType<typeof vi.fn> }
postStateToWebview: ReturnType<typeof vi.fn>
translateSessionEvent: ReturnType<typeof vi.fn>
messageTranslatorState: MessageTranslatorState
}
return {
@@ -64,11 +64,6 @@ export class SdkSessionEventCoordinator {
}
const result = this.translateSessionEvent(event, this.options.messageTranslatorState)
if (event.type === "pending_prompt_submitted") {
this.options.messageTranslatorState.clearTurnOutcome()
this.options.sessions.setRunning(true)
this.options.setTurnPhase?.("streaming")
}
const zeroCostPromise = this.zeroCostForFreeClineModel(result)
if (zeroCostPromise) {
await zeroCostPromise
@@ -135,12 +130,7 @@ export class SdkSessionEventCoordinator {
// completed/awaiting_followup/error above; without posting here the webview would stay on
// the prior phase (footer stuck on the streaming/scroll state). The webview reducer gates
// turnState by seq, so an extra no-message post is safe.
if (
result.messages.length > 0 ||
result.sessionEnded ||
result.turnComplete ||
event.type === "pending_prompt_submitted"
) {
if (result.messages.length > 0 || result.sessionEnded || result.turnComplete) {
this.options.postStateToWebview().catch((err) => {
Logger.error("[SdkController] Failed to post state after event:", err)
})
@@ -3,10 +3,6 @@ import { describe, expect, it } from "vitest"
import { isToolAutoApproved } from "./sdk-tool-policies"
describe("isToolAutoApproved", () => {
it("does not auto-approve command tools by default", () => {
expect(isToolAutoApproved("run_commands", DEFAULT_AUTO_APPROVAL_SETTINGS)).toBe(false)
})
it("uses executeSafeCommands as the single command approval flag", () => {
const settings = {
...DEFAULT_AUTO_APPROVAL_SETTINGS,
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest"
import { formatCommandForTerminal } from "./vscode-run-commands-tool"
describe("formatCommandForTerminal", () => {
it.each([
{
name: "raw shell command",
input: "which git",
expected: "which git",
},
{
name: "raw shell command with pipes and quotes",
input: "git status --short | sed -n '1,20p'",
expected: "git status --short | sed -n '1,20p'",
},
{
name: "structured command with omitted args",
input: { command: "which git" },
expected: "which git",
},
{
name: "structured shell command with omitted args and metacharacters",
input: { command: "git status --short | head -20" },
expected: "git status --short | head -20",
},
{
name: "structured executable with explicit empty args",
input: { command: "/tmp/path with spaces/tool", args: [] },
expected: "'/tmp/path with spaces/tool'",
},
{
name: "structured executable with simple args",
input: { command: "which", args: ["git"] },
expected: "which git",
},
{
name: "structured executable with spaced args",
input: { command: "echo", args: ["hello world", "again"] },
expected: "echo 'hello world' again",
},
{
name: "structured executable with apostrophe args",
input: { command: "printf", args: ["it's ok"] },
expected: "printf 'it'\\''s ok'",
},
{
name: "structured executable with empty arg",
input: { command: "printf", args: [""] },
expected: "printf ''",
},
{
name: "structured executable with shell metacharacters in args",
input: { command: "echo", args: ["$HOME", "a&b", "semi;colon", "paren(value)"] },
expected: "echo '$HOME' 'a&b' 'semi;colon' 'paren(value)'",
},
{
name: "structured executable with quoted args",
input: { command: "node", args: ["-e", 'console.log("hi")'] },
expected: "node -e 'console.log(\"hi\")'",
},
])("$name", ({ input, expected }) => {
expect(formatCommandForTerminal(input)).toBe(expected)
})
it("quotes multiple structured args that need shell escaping", () => {
expect(formatCommandForTerminal({ command: "echo", args: ["hello world", "it's ok"] })).toBe(
"echo 'hello world' 'it'\\''s ok'",
)
})
})
@@ -53,13 +53,10 @@ function quoteShellArg(arg: string): string {
return `'${arg.replace(/'/g, `'\\''`)}'`
}
export function formatCommandForTerminal(command: ShellCommand): string {
function formatCommandForTerminal(command: ShellCommand): string {
if (typeof command === "string") {
return command
}
if (!("args" in command)) {
return command.command
}
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
}
@@ -11,7 +11,7 @@ describe("ClineError", () => {
it("should return Entitlement for the SDK ClinePass subscription message", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
@@ -19,7 +19,7 @@ describe("ClineError", () => {
it("should return Entitlement for the SDK ClinePass subscription message with a different app URL", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-8OFF&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-100&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
-9
View File
@@ -1267,15 +1267,6 @@ export class McpHub {
await this.notifyWebviewOfServerChanges()
}
async reconcileMcpServersFromSettingsRPC(): Promise<McpServer[]> {
const settings = await this.readPostWriteMcpSettings()
await this.updateServerConnectionsRPC(settings.mcpServers as Record<string, McpServerConfig>)
await this.notifyWebviewOfServerChanges()
const serverOrder = Object.keys(settings.mcpServers || {})
return this.getSortedMcpServers(serverOrder)
}
async getLatestMcpServersRPC(): Promise<McpServer[]> {
const settings = await this.readAndValidateMcpSettingsFile()
if (!settings) {
+218
View File
@@ -0,0 +1,218 @@
import { execa } from "execa"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
/**
* Validates that the workspace path is valid and writable for Git operations
* @param workspacePath The workspace path to validate
* @throws Error if the workspace path is invalid or not writable
*/
export async function validateWorkspacePath(workspacePath: string): Promise<void> {
// Check if workspace path is valid
if (!workspacePath || workspacePath === "/") {
throw new Error(`Invalid workspace path: ${workspacePath}. Cannot initialize Git repository.`)
}
// Check if the directory exists
try {
await execa("test", ["-d", workspacePath])
} catch (_error) {
throw new Error(`Workspace path does not exist or is not a directory: ${workspacePath}`)
}
// Check if the directory is writable
try {
const testFile = path.join(workspacePath, ".cline_write_test")
await execa("touch", [testFile])
await execa("rm", [testFile])
} catch (error) {
throw new Error(`Workspace path is not writable: ${workspacePath}. Error: ${error.message}`)
}
Logger.log(`Validated workspace path: ${workspacePath}`)
}
/**
* Cleans up any existing Git repository in the specified workspace path
* @param workspacePath The workspace path to clean up
*/
async function cleanupPreviousGit(workspacePath: string): Promise<void> {
const gitDir = path.join(workspacePath, ".git")
try {
// Check if .git directory exists using execa since we're already using it
try {
await execa("test", ["-d", gitDir])
// If we get here, the directory exists
Logger.log(`Removing existing Git repository in ${workspacePath}`)
// Use rm -rf to remove the directory
await execa("rm", ["-rf", gitDir])
Logger.log(`Removed existing Git repository`)
} catch (_error) {
// Directory doesn't exist, which is fine
Logger.log(`No existing Git repository found in ${workspacePath}`)
}
} catch (error) {
Logger.log(`Warning: Failed to remove existing Git repository: ${error.message}`)
}
}
/**
* Initializes a Git repository in the specified workspace path
* @param workspacePath The workspace path to initialize Git in
* @returns True if the repository was newly initialized
*/
export async function initializeGitRepository(workspacePath: string): Promise<boolean> {
// Validate workspace path before proceeding
await validateWorkspacePath(workspacePath)
// Clean up any existing Git repository
await cleanupPreviousGit(workspacePath)
// Initialize a new Git repository
Logger.log(`Initializing Git repository in ${workspacePath}`)
try {
await execa("git", ["init"], { cwd: workspacePath })
await execa("git", ["config", "user.name", "Cline Evaluation"], { cwd: workspacePath })
await execa("git", ["config", "user.email", "cline@example.com"], { cwd: workspacePath })
// Try to create an initial commit, but don't fail if there are no files to commit
try {
// Check if there are any files to commit
const { stdout: statusOutput } = await execa("git", ["status", "--porcelain"], { cwd: workspacePath })
if (statusOutput.trim()) {
// There are files to commit
await execa("git", ["add", "."], { cwd: workspacePath })
await execa("git", ["commit", "-m", "Initial commit for evaluation"], { cwd: workspacePath })
Logger.log("Created initial Git commit in " + workspacePath)
} else {
// No files to commit, create an empty commit
Logger.log("No files to commit, creating empty initial commit")
try {
// Create an empty commit with --allow-empty
await execa("git", ["commit", "--allow-empty", "-m", "Initial empty commit for evaluation"], {
cwd: workspacePath,
})
Logger.log("Created empty initial commit in " + workspacePath)
} catch (emptyCommitError) {
// Even empty commit failed, but we'll continue anyway
Logger.log(`Warning: Failed to create empty commit: ${emptyCommitError.message}`)
}
}
} catch (commitError) {
// Initial commit failed, but Git is still initialized
Logger.log(`Warning: Failed to create initial commit: ${commitError.message}`)
Logger.log("Continuing without initial commit")
}
return true
} catch (gitError) {
// Only throw if Git initialization itself failed
const errorMessage = `Failed to initialize Git repository: ${gitError.message}`
Logger.log(errorMessage)
throw new Error(errorMessage)
}
}
/**
* Gets the file changes between the current state and the initial state
* @param workspacePath The workspace path to check for changes
* @returns Object containing lists of created, modified, and deleted files, plus the full diff
*/
export async function getFileChanges(workspacePath: string): Promise<{
created: string[]
modified: string[]
deleted: string[]
diff: string
}> {
// Validate workspace path before proceeding
await validateWorkspacePath(workspacePath)
// Make sure all changes are staged so they appear in the diff
Logger.log(`Staging all changes in ${workspacePath} for diff`)
try {
// First check if there are any untracked files
const { stdout: untrackedOutput } = await execa("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: workspacePath,
})
if (untrackedOutput.trim()) {
Logger.log(`Found untracked files: ${untrackedOutput}`)
}
// Stage all changes including untracked files
await execa("git", ["add", "-A"], { cwd: workspacePath })
Logger.log("Staged all changes for diff")
} catch (error) {
Logger.log(`Warning: Failed to stage changes: ${error.message}`)
}
try {
// Get list of changed files
const { stdout: statusOutput } = await execa("git", ["status", "--porcelain"], { cwd: workspacePath })
Logger.log(`Git status output: ${statusOutput || "(empty)"}`)
const created: string[] = []
const modified: string[] = []
const deleted: string[] = []
// Parse git status output
statusOutput
.split("\n")
.filter(Boolean)
.forEach((line) => {
const status = line.substring(0, 2).trim()
const file = line.substring(3)
if (status === "A" || status === "??") {
created.push(file)
} else if (status === "M") {
modified.push(file)
} else if (status === "D") {
deleted.push(file)
}
})
// Get the full diff - include both staged and unstaged changes
const { stdout: diffOutput } = await execa("git", ["diff", "--staged"], { cwd: workspacePath })
Logger.log(`Git diff output length: ${diffOutput.length} characters`)
// If there's no diff, try getting the diff of unstaged changes
let finalDiff = diffOutput
if (!finalDiff) {
const { stdout: unstaged } = await execa("git", ["diff"], { cwd: workspacePath })
finalDiff = unstaged
Logger.log(`Unstaged git diff output length: ${unstaged.length} characters`)
}
return {
created,
modified,
deleted,
diff: finalDiff,
}
} catch (error) {
// Throw the error instead of returning a fallback
const errorMessage = `Error getting file changes: ${error.message}`
Logger.log(errorMessage)
throw new Error(errorMessage)
}
}
/**
* Calculates the tool success rate based on calls and failures
* @param toolCalls Record of tool calls by name
* @param toolFailures Record of tool failures by name
* @returns The success rate as a number between 0 and 1
*/
export function calculateToolSuccessRate(toolCalls: Record<string, number>, toolFailures: Record<string, number>): number {
const totalCalls = Object.values(toolCalls).reduce((a, b) => a + b, 0)
const totalFailures = Object.values(toolFailures).reduce((a, b) => a + b, 0)
if (totalCalls === 0) {
return 1.0 // No calls means no failures
}
return 1.0 - totalFailures / totalCalls
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Module for managing test mode state across the extension
* This provides a centralized way to check if the extension is running in test mode
* instead of relying on process.env which may not be consistent across different parts of the extension
*/
import * as fs from "fs"
import * as path from "path"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { createTestServer, shutdownTestServer } from "./TestServer"
// State variable
let isTestMode = false
/**
* Sets the test mode state
* @param value Whether test mode is enabled
*/
function setTestMode(value: boolean): void {
isTestMode = value
}
/**
* Checks if the extension is running in test mode
* @returns True if in test mode, false otherwise
*/
function isInTestMode(): boolean {
return isTestMode
}
/**
* Check if we're in test mode by looking for evals.env file in workspace folders
*/
async function checkForTestMode(): Promise<boolean> {
// Get all workspace folders
const workspaceFolders = await HostProvider.workspace.getWorkspacePaths({})
// Check each workspace folder for an evals.env file
for (const folder of workspaceFolders.paths) {
const evalsEnvPath = path.join(folder, "evals.env")
if (fs.existsSync(evalsEnvPath)) {
Logger.log(`Found evals.env file at ${evalsEnvPath}, activating test mode`)
return true
}
}
return false
}
/**
* Initialize test mode detection and setup file watchers
* @param webviewProvider The webview provider instance
*/
export async function initializeTestMode(webviewProvider?: any): Promise<vscode.Disposable[]> {
const disposables: vscode.Disposable[] = []
// Check if we're in test mode
const IS_TEST = await checkForTestMode()
// Set test mode state for other parts of the code
if (IS_TEST) {
Logger.log("Test mode detected: Setting test mode state to true")
setTestMode(true)
vscode.commands.executeCommand("setContext", "cline.isTestMode", true)
// Set up test server if in test mode
createTestServer(webviewProvider)
}
// Watch for evals.env files being added or removed
const evalsEnvWatcher = vscode.workspace.createFileSystemWatcher("**/evals.env")
// When an evals.env file is created, activate test mode if not already active
evalsEnvWatcher.onDidCreate(async (uri) => {
Logger.log(`evals.env file created at ${uri.fsPath}`)
if (!isInTestMode()) {
setTestMode(true)
vscode.commands.executeCommand("setContext", "cline.isTestMode", true)
createTestServer(webviewProvider)
}
})
// When an evals.env file is deleted, deactivate test mode if no other evals.env files exist
evalsEnvWatcher.onDidDelete(async (uri) => {
Logger.log(`evals.env file deleted at ${uri.fsPath}`)
// Only deactivate if this was the last evals.env file
if (!checkForTestMode()) {
setTestMode(false)
vscode.commands.executeCommand("setContext", "cline.isTestMode", false)
shutdownTestServer()
}
})
disposables.push(evalsEnvWatcher)
return disposables
}
/**
* Clean up test mode resources
*/
export function cleanupTestMode(): void {
// Shutdown the test server if it exists
shutdownTestServer()
}
+450
View File
@@ -0,0 +1,450 @@
import { getSavedApiConversationHistory } from "@core/storage/disk"
import { WebviewProvider } from "@core/webview"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { ApiProvider } from "@shared/api"
import { HistoryItem } from "@shared/HistoryItem"
import { execa } from "execa"
import * as http from "http"
import * as path from "path"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { ExtensionRegistryInfo } from "@/registry"
import { Logger } from "@/shared/services/Logger"
import { getCwd } from "@/utils/path"
import { calculateToolSuccessRate, getFileChanges, initializeGitRepository, validateWorkspacePath } from "./GitHelper"
/**
* Creates a tracker to monitor tool calls and failures during task execution
* @returns Object tracking tool calls and failures
*/
function createToolCallTracker(): {
toolCalls: Record<string, number>
toolFailures: Record<string, number>
} {
const tracker = {
toolCalls: {} as Record<string, number>,
toolFailures: {} as Record<string, number>,
}
return tracker
}
// Task completion tracking
let _taskCompletionResolver: (() => void) | null = null
// Function to create a new task completion promise
function createTaskCompletionTracker(): Promise<void> {
// Create a new promise that will resolve when the task is completed
return new Promise<void>((resolve) => {
_taskCompletionResolver = resolve
})
}
let testServer: http.Server | undefined
let messageCatcherDisposable: vscode.Disposable | undefined
/**
* Updates the auto approval settings to enable all actions
* @param context The VSCode extension context
* @param controller The webview provider instance
*/
async function updateAutoApprovalSettings(controller?: Controller) {
try {
const autoApprovalSettings = controller?.stateManager.getGlobalSettingsKey("autoApprovalSettings")
// Enable all actions
const updatedSettings: AutoApprovalSettings = {
...(autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS),
actions: {
readFiles: true,
readFilesExternally: true,
editFiles: true,
editFilesExternally: true,
executeSafeCommands: true,
executeAllCommands: true,
useBrowser: false, // Keep browser disabled for tests
useMcp: false, // Keep MCP disabled for tests
},
}
controller?.stateManager.setGlobalState("autoApprovalSettings", updatedSettings)
Logger.log("Auto approval settings updated for test mode")
// Update the webview with the new state
if (controller) {
await controller.postStateToWebview()
}
} catch (error) {
Logger.log(`Error updating auto approval settings: ${error}`)
}
}
/**
* Creates and starts an HTTP server for test automation
* @param webviewProvider The webview provider instance to use for message catching
* @returns The created HTTP server instance
*/
export async function createTestServer(controller: Controller): Promise<http.Server> {
// Try to show the Cline sidebar
Logger.log("[createTestServer] Opening Cline in sidebar...")
vscode.commands.executeCommand(`workbench.view.${ExtensionRegistryInfo.name}-ActivityBar`)
// Then ensure the webview is focused/loaded
vscode.commands.executeCommand(`${ExtensionRegistryInfo.views.Sidebar}.focus`)
// Update auto approval settings is available
await updateAutoApprovalSettings(controller)
const PORT = 9876
testServer = http.createServer((req, res) => {
// Set CORS headers
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
// Handle preflight requests
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
// Handle shutdown request
if (req.method === "POST" && req.url === "/shutdown") {
res.writeHead(200)
res.end(JSON.stringify({ success: true, message: "Server shutting down" }))
// Shut down the server after sending the response
setTimeout(() => {
shutdownTestServer()
}, 100)
return
}
// Only handle POST requests to /task
if (req.method !== "POST" || req.url !== "/task") {
res.writeHead(404)
res.end(JSON.stringify({ error: "Not found" }))
return
}
// Parse the request body
let body = ""
req.on("data", (chunk) => {
body += chunk.toString()
})
req.on("end", async () => {
try {
// Parse the JSON body
const { task, apiKey } = JSON.parse(body)
if (!task) {
res.writeHead(400)
res.end(JSON.stringify({ error: "Missing task parameter" }))
return
}
// Get a visible webview instance
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview || !visibleWebview.controller) {
res.writeHead(500)
res.end(JSON.stringify({ error: "No active Cline instance found" }))
return
}
// Initiate a new task
Logger.log(`Test server initiating task: ${task}`)
try {
// Get and validate the workspace path
const workspacePath = await getCwd()
Logger.log(`Using workspace path: ${workspacePath}`)
// Validate workspace path before proceeding with any operations
try {
await validateWorkspacePath(workspacePath)
} catch (error) {
Logger.log(`Workspace validation failed: ${error.message}`)
res.writeHead(500)
res.end(
JSON.stringify({
error: `Workspace validation failed: ${error.message}. Please open a workspace folder in VSCode before running the test.`,
workspacePath,
}),
)
return
}
// Initialize Git repository before starting the task
try {
const wasNewlyInitialized = await initializeGitRepository(workspacePath)
if (wasNewlyInitialized) {
Logger.log(`Initialized new Git repository in ${workspacePath} before task start`)
} else {
Logger.log(`Using existing Git repository in ${workspacePath} before task start`)
}
// Log directory contents before task start
try {
const { stdout: lsOutput } = await execa("ls", ["-la", workspacePath])
Logger.log(`Directory contents before task start:\n${lsOutput}`)
} catch (lsError) {
Logger.log(`Warning: Failed to list directory contents: ${lsError.message}`)
}
} catch (gitError) {
Logger.log(`Warning: Git initialization failed: ${gitError.message}`)
Logger.log("Continuing without Git initialization")
}
// Clear any existing task
await visibleWebview.controller.clearTask()
// If API key is provided, update the API configuration
if (apiKey) {
Logger.log("API key provided, updating API configuration")
// Get current API configuration
const apiConfiguration = visibleWebview.controller.stateManager.getApiConfiguration()
// Update API configuration with API key
const updatedConfig = {
...apiConfiguration,
apiProvider: "cline" as ApiProvider,
clineAccountId: apiKey,
}
// Store the API key securely
visibleWebview.controller.stateManager.setSecret("clineAccountId", apiKey)
visibleWebview.controller.stateManager.setApiConfiguration(updatedConfig)
// Update cache service to use cline provider
const currentConfig = visibleWebview.controller.stateManager.getApiConfiguration()
visibleWebview.controller.stateManager.setApiConfiguration({
...currentConfig,
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
// Post state to webview to reflect changes
await visibleWebview.controller.postStateToWebview()
}
// Ensure we're in Act mode before initiating the task
const { mode } = await visibleWebview.controller.getStateToPostToWebview()
if (mode === "plan") {
// Switch to Act mode if currently in Plan mode
await visibleWebview.controller.togglePlanActMode("act")
}
// Initialize tool call tracker
const toolTracker = createToolCallTracker()
// Record task start time
const taskStartTime = Date.now()
// Initiate the new task
const result = await visibleWebview.controller.initTask(task)
// Try to get the task ID directly from the result or from the state
let taskId: string | undefined
if (typeof result === "string") {
// If initTask returns the task ID directly
taskId = result
} else {
// Wait a moment for the state to update
await new Promise((resolve) => setTimeout(resolve, 1000))
// Try to get the task ID from the controller's state
const state = await visibleWebview.controller.getStateToPostToWebview()
taskId = state.currentTaskItem?.id
// If still not found, try polling a few times
if (!taskId) {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 500))
const updatedState = await visibleWebview.controller.getStateToPostToWebview()
taskId = updatedState.currentTaskItem?.id
if (taskId) {
break
}
}
}
}
if (!taskId) {
throw new Error("Failed to get task ID after initiating task")
}
Logger.log(`Task initiated with ID: ${taskId}`)
// Create a completion tracker for this task
const completionPromise = createTaskCompletionTracker()
// Wait for the task to complete with a timeout
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error("Task completion timeout")), 15 * 60 * 1000) // 15 minute timeout
})
try {
// Wait for either completion or timeout
await Promise.race([completionPromise, timeoutPromise])
// Get task history and metrics
const taskHistory = await visibleWebview.controller.getStateToPostToWebview()
const taskData = taskHistory.taskHistory?.find((t: HistoryItem) => t.id === taskId)
// Get messages and API conversation history
let messages: any[] = []
let apiConversationHistory: any[] = []
try {
if (typeof taskId === "string") {
messages = []
}
} catch (error) {
Logger.log(`Error getting saved Cline messages: ${error}`)
}
try {
if (typeof taskId === "string") {
apiConversationHistory = await getSavedApiConversationHistory(taskId)
}
} catch (error) {
Logger.log(`Error getting saved API conversation history: ${error}`)
}
// Get file changes
let fileChanges
try {
// Get the workspace path using our helper function
const workspacePath = await getCwd()
Logger.log(`Getting file changes from workspace path: ${workspacePath}`)
// Log directory contents for debugging
try {
const { stdout: lsOutput } = await execa("ls", ["-la", workspacePath])
Logger.log(`Directory contents after task completion:\n${lsOutput}`)
} catch (lsError) {
Logger.log(`Warning: Failed to list directory contents: ${lsError.message}`)
}
// Get file changes using Git
fileChanges = await getFileChanges(workspacePath)
// If no changes were detected, use a fallback method
if (!fileChanges.created.length && !fileChanges.modified.length && !fileChanges.deleted.length) {
Logger.log("No changes detected by Git, using fallback directory scan")
// Try to get a list of all files in the directory
try {
const { stdout: findOutput } = await execa("find", [
workspacePath,
"-type",
"f",
"-not",
"-path",
"*/.*",
"-not",
"-path",
"*/node_modules/*",
])
const files = findOutput.split("\n").filter(Boolean)
// Add all files as "created" since we can't determine which ones are new
fileChanges.created = files.map((file) => path.relative(workspacePath, file))
Logger.log(`Fallback found ${fileChanges.created.length} files`)
} catch (findError) {
Logger.log(`Warning: Fallback directory scan failed: ${findError.message}`)
}
}
} catch (fileChangeError) {
Logger.log(`Error getting file changes: ${fileChangeError.message}`)
throw new Error(`Error getting file changes: ${fileChangeError.message}`)
}
// Get tool metrics
const toolMetrics = {
toolCalls: toolTracker.toolCalls,
toolFailures: toolTracker.toolFailures,
totalToolCalls: Object.values(toolTracker.toolCalls).reduce((a, b) => a + b, 0),
totalToolFailures: Object.values(toolTracker.toolFailures).reduce((a, b) => a + b, 0),
toolSuccessRate: calculateToolSuccessRate(toolTracker.toolCalls, toolTracker.toolFailures),
}
// Calculate task duration
const taskDuration = Date.now() - taskStartTime
// Return comprehensive response with all metrics and data
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
success: true,
taskId,
completed: true,
metrics: {
tokensIn: taskData?.tokensIn || 0,
tokensOut: taskData?.tokensOut || 0,
cost: taskData?.totalCost || 0,
duration: taskDuration,
...toolMetrics,
},
messages,
apiConversationHistory,
files: fileChanges,
}),
)
} catch (_timeoutError) {
// Task didn't complete within the timeout period
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
success: true,
taskId,
completed: false,
timeout: true,
}),
)
}
} catch (error) {
Logger.log(`Error initiating task: ${error}`)
res.writeHead(500)
res.end(JSON.stringify({ error: `Failed to initiate task: ${error}` }))
}
} catch (error) {
res.writeHead(400)
res.end(JSON.stringify({ error: `Invalid JSON: ${error}` }))
}
})
})
testServer.listen(PORT, () => {
Logger.log(`Test server listening on port ${PORT}`)
})
// Handle server errors
testServer.on("error", (error) => {
Logger.log(`Test server error: ${error}`)
})
return testServer
}
/**
* Shuts down the test server if it exists
*/
export function shutdownTestServer() {
if (testServer) {
testServer.close()
Logger.log("Test server shut down")
testServer = undefined
}
// Dispose of the message catcher if it exists
if (messageCatcherDisposable) {
messageCatcherDisposable.dispose()
messageCatcherDisposable = undefined
}
}
@@ -33,11 +33,11 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
actions: {
readFiles: true,
readFilesExternally: true,
editFiles: true,
editFiles: false,
editFilesExternally: true,
executeSafeCommands: false,
executeAllCommands: true,
useBrowser: true,
executeSafeCommands: true,
executeAllCommands: false,
useBrowser: false,
useMcp: true,
},
enableNotifications: false,
@@ -3,6 +3,7 @@ import {
OpenAiCompatibleModelInfo,
OpenRouterModelInfo,
ModelsApiConfiguration as ProtoApiConfiguration,
ApiProvider as ProtoApiProvider,
OcaModelInfo as ProtoOcaModelInfo,
ThinkingConfig,
} from "@shared/proto/cline/models"
@@ -240,12 +241,208 @@ function convertProtoToOpenAiCompatibleModelInfo(
}
}
// Provider ids travel over the wire as plain strings (matching the `ApiProvider`
// union in `@shared/api`), so no enum mapping is needed in either direction.
// This thin helper just supplies the default and the single cast boundary for
// callers reading a provider id off a proto message.
export function convertProtoToApiProvider(provider: string | undefined): ApiProvider {
return (provider || "anthropic") as ApiProvider
// Convert application ApiProvider to proto ApiProvider
function convertApiProviderToProto(provider: string | undefined): ProtoApiProvider {
switch (provider) {
case "anthropic":
return ProtoApiProvider.ANTHROPIC
case "openrouter":
return ProtoApiProvider.OPENROUTER
case "bedrock":
return ProtoApiProvider.BEDROCK
case "vertex":
return ProtoApiProvider.VERTEX
case "openai":
return ProtoApiProvider.OPENAI
case "ollama":
return ProtoApiProvider.OLLAMA
case "lmstudio":
return ProtoApiProvider.LMSTUDIO
case "gemini":
return ProtoApiProvider.GEMINI
case "openai-native":
return ProtoApiProvider.OPENAI_NATIVE
case "requesty":
return ProtoApiProvider.REQUESTY
case "together":
return ProtoApiProvider.TOGETHER
case "deepseek":
return ProtoApiProvider.DEEPSEEK
case "qwen":
return ProtoApiProvider.QWEN
case "qwen-code":
return ProtoApiProvider.QWEN_CODE
case "doubao":
return ProtoApiProvider.DOUBAO
case "mistral":
return ProtoApiProvider.MISTRAL
case "vscode-lm":
return ProtoApiProvider.VSCODE_LM
case "cline":
return ProtoApiProvider.CLINE
case "cline-pass":
return ProtoApiProvider.CLINE_PASS
case "litellm":
return ProtoApiProvider.LITELLM
case "moonshot":
return ProtoApiProvider.MOONSHOT
case "huggingface":
return ProtoApiProvider.HUGGINGFACE
case "nebius":
return ProtoApiProvider.NEBIUS
case "wandb":
return ProtoApiProvider.WANDB
case "fireworks":
return ProtoApiProvider.FIREWORKS
case "asksage":
return ProtoApiProvider.ASKSAGE
case "xai":
return ProtoApiProvider.XAI
case "sambanova":
return ProtoApiProvider.SAMBANOVA
case "cerebras":
return ProtoApiProvider.CEREBRAS
case "groq":
return ProtoApiProvider.GROQ
case "baseten":
return ProtoApiProvider.BASETEN
case "sapaicore":
return ProtoApiProvider.SAPAICORE
case "claude-code":
return ProtoApiProvider.CLAUDE_CODE
case "huawei-cloud-maas":
return ProtoApiProvider.HUAWEI_CLOUD_MAAS
case "vercel-ai-gateway":
return ProtoApiProvider.VERCEL_AI_GATEWAY
case "zai":
return ProtoApiProvider.ZAI
case "dify":
return ProtoApiProvider.DIFY
case "oca":
return ProtoApiProvider.OCA
case "aihubmix":
return ProtoApiProvider.AIHUBMIX
case "minimax":
return ProtoApiProvider.MINIMAX
case "hicap":
return ProtoApiProvider.HICAP
case "nousResearch":
return ProtoApiProvider.NOUSRESEARCH
case "openai-codex":
return ProtoApiProvider.OPENAI_CODEX
case "poolside":
return ProtoApiProvider.POOLSIDE
case "v0":
return ProtoApiProvider.V0
case "xiaomi":
return ProtoApiProvider.XIAOMI
case "zai-coding-plan":
return ProtoApiProvider.ZAI_CODING_PLAN
default:
return ProtoApiProvider.ANTHROPIC
}
}
// Convert proto ApiProvider to application ApiProvider
export function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
switch (provider) {
case ProtoApiProvider.ANTHROPIC:
return "anthropic"
case ProtoApiProvider.OPENROUTER:
return "openrouter"
case ProtoApiProvider.BEDROCK:
return "bedrock"
case ProtoApiProvider.VERTEX:
return "vertex"
case ProtoApiProvider.OPENAI:
return "openai"
case ProtoApiProvider.OLLAMA:
return "ollama"
case ProtoApiProvider.LMSTUDIO:
return "lmstudio"
case ProtoApiProvider.GEMINI:
return "gemini"
case ProtoApiProvider.OPENAI_NATIVE:
return "openai-native"
case ProtoApiProvider.REQUESTY:
return "requesty"
case ProtoApiProvider.TOGETHER:
return "together"
case ProtoApiProvider.DEEPSEEK:
return "deepseek"
case ProtoApiProvider.QWEN:
return "qwen"
case ProtoApiProvider.QWEN_CODE:
return "qwen-code"
case ProtoApiProvider.DOUBAO:
return "doubao"
case ProtoApiProvider.MISTRAL:
return "mistral"
case ProtoApiProvider.VSCODE_LM:
return "vscode-lm"
case ProtoApiProvider.CLINE:
return "cline"
case ProtoApiProvider.CLINE_PASS:
return "cline-pass"
case ProtoApiProvider.LITELLM:
return "litellm"
case ProtoApiProvider.MOONSHOT:
return "moonshot"
case ProtoApiProvider.HUGGINGFACE:
return "huggingface"
case ProtoApiProvider.NEBIUS:
return "nebius"
case ProtoApiProvider.WANDB:
return "wandb"
case ProtoApiProvider.FIREWORKS:
return "fireworks"
case ProtoApiProvider.ASKSAGE:
return "asksage"
case ProtoApiProvider.XAI:
return "xai"
case ProtoApiProvider.SAMBANOVA:
return "sambanova"
case ProtoApiProvider.CEREBRAS:
return "cerebras"
case ProtoApiProvider.GROQ:
return "groq"
case ProtoApiProvider.BASETEN:
return "baseten"
case ProtoApiProvider.SAPAICORE:
return "sapaicore"
case ProtoApiProvider.CLAUDE_CODE:
return "claude-code"
case ProtoApiProvider.HUAWEI_CLOUD_MAAS:
return "huawei-cloud-maas"
case ProtoApiProvider.VERCEL_AI_GATEWAY:
return "vercel-ai-gateway"
case ProtoApiProvider.ZAI:
return "zai"
case ProtoApiProvider.HICAP:
return "hicap"
case ProtoApiProvider.DIFY:
return "dify"
case ProtoApiProvider.OCA:
return "oca"
case ProtoApiProvider.AIHUBMIX:
return "aihubmix"
case ProtoApiProvider.MINIMAX:
return "minimax"
case ProtoApiProvider.NOUSRESEARCH:
return "nousResearch"
case ProtoApiProvider.OPENAI_CODEX:
return "openai-codex"
case ProtoApiProvider.POOLSIDE:
return "poolside"
case ProtoApiProvider.V0:
return "v0"
case ProtoApiProvider.XIAOMI:
return "xiaomi"
case ProtoApiProvider.ZAI_CODING_PLAN:
return "zai-coding-plan"
default:
return "anthropic"
}
}
// Converts application ApiConfiguration to proto ApiConfiguration
@@ -339,7 +536,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
hicapModelId: config.hicapModelId,
// Plan mode configurations
planModeApiProvider: config.planModeApiProvider,
planModeApiProvider: config.planModeApiProvider ? convertApiProviderToProto(config.planModeApiProvider) : undefined,
planModeApiModelId: config.planModeApiModelId,
planModeThinkingBudgetTokens: config.planModeThinkingBudgetTokens,
geminiPlanModeThinkingLevel: config.geminiPlanModeThinkingLevel,
@@ -385,7 +582,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeVercelAiGatewayModelInfo: convertModelInfoToProtoOpenRouter(config.planModeVercelAiGatewayModelInfo),
// Act mode configurations
actModeApiProvider: config.actModeApiProvider,
actModeApiProvider: config.actModeApiProvider ? convertApiProviderToProto(config.actModeApiProvider) : undefined,
actModeApiModelId: config.actModeApiModelId,
actModeThinkingBudgetTokens: config.actModeThinkingBudgetTokens,
geminiActModeThinkingLevel: config.geminiActModeThinkingLevel,
@@ -0,0 +1,192 @@
{
"list": [
{
"value": "cline",
"label": "Cline"
},
{
"value": "cline-pass",
"label": "ClinePass"
},
{
"value": "openai-codex",
"label": "ChatGPT Subscription"
},
{
"value": "zai-coding-plan",
"label": "Z.AI Coding Plan"
},
{
"value": "gemini",
"label": "Google Gemini"
},
{
"value": "openai",
"label": "OpenAI Compatible"
},
{
"value": "anthropic",
"label": "Anthropic"
},
{
"value": "bedrock",
"label": "Amazon Bedrock"
},
{
"value": "vscode-lm",
"label": "GitHub Copilot"
},
{
"value": "deepseek",
"label": "DeepSeek"
},
{
"value": "openai-native",
"label": "OpenAI"
},
{
"value": "openrouter",
"label": "OpenRouter"
},
{
"value": "ollama",
"label": "Ollama"
},
{
"value": "vertex",
"label": "GCP Vertex AI"
},
{
"value": "litellm",
"label": "LiteLLM"
},
{
"value": "claude-code",
"label": "Claude Code"
},
{
"value": "sapaicore",
"label": "SAP AI Core"
},
{
"value": "mistral",
"label": "Mistral"
},
{
"value": "zai",
"label": "Z AI"
},
{
"value": "groq",
"label": "Groq"
},
{
"value": "poolside",
"label": "Poolside"
},
{
"value": "cerebras",
"label": "Cerebras"
},
{
"value": "vercel-ai-gateway",
"label": "Vercel AI Gateway"
},
{
"value": "v0",
"label": "Vercel v0"
},
{
"value": "baseten",
"label": "Baseten"
},
{
"value": "requesty",
"label": "Requesty"
},
{
"value": "fireworks",
"label": "Fireworks AI"
},
{
"value": "together",
"label": "Together"
},
{
"value": "qwen",
"label": "Alibaba Qwen"
},
{
"value": "qwen-code",
"label": "Qwen Code"
},
{
"value": "doubao",
"label": "Bytedance Doubao"
},
{
"value": "lmstudio",
"label": "LM Studio"
},
{
"value": "moonshot",
"label": "Moonshot"
},
{
"value": "huggingface",
"label": "Hugging Face"
},
{
"value": "nebius",
"label": "Nebius AI Studio"
},
{
"value": "asksage",
"label": "AskSage"
},
{
"value": "xai",
"label": "xAI"
},
{
"value": "sambanova",
"label": "SambaNova"
},
{
"value": "huawei-cloud-maas",
"label": "Huawei Cloud MaaS"
},
{
"value": "dify",
"label": "Dify.ai"
},
{
"value": "oca",
"label": "Oracle Code Assist"
},
{
"value": "minimax",
"label": "MiniMax"
},
{
"value": "hicap",
"label": "Hicap"
},
{
"value": "aihubmix",
"label": "AIhubmix"
},
{
"value": "nousResearch",
"label": "NousResearch"
},
{
"value": "wandb",
"label": "W&B Inference by CoreWeave"
},
{
"value": "xiaomi",
"label": "Xiaomi"
}
]
}
@@ -10,23 +10,6 @@ export interface StartSessionResult {
sessionId: string
}
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
export function truncateCommandOutput(output: string): string {
return output
}
export function createShellExecutor() {
return async () => ""
}
export function createShellTool(execute: unknown) {
return {
name: "run_commands",
execute,
}
}
export interface SessionHistoryRecord {
id: string
metadata?: Record<string, unknown>
+12 -10
View File
@@ -4,12 +4,12 @@ import { expect } from "@playwright/test"
import { E2E_WORKSPACE_TYPES, e2e } from "./utils/helpers"
// File edits are performed by the SDK's `editor` tool executor, which writes
// the file directly (Node fs) after the tool call is approved. It does not
// stream the edit through DiffViewProvider, so no diff editor tab (e.g.
// "test.ts: Original ↔ Cline's Changes") opens. This test asserts the default
// auto-approval flow: ask row appears without manual approval buttons, the file
// is modified on disk, and the turn-ending completion text appears.
e2e.describe("File Edit Auto-Approval", () => {
// the file directly (Node fs) after the user approves the tool call — it does
// not stream the edit through DiffViewProvider, so no diff editor tab (e.g.
// "test.ts: Original ↔ Cline's Changes") opens. This test asserts the
// approval flow: approval ask row → Save → file modified on disk →
// turn-ending completion text.
e2e.describe("File Edit Approval", () => {
E2E_WORKSPACE_TYPES.forEach(({ title, workspaceType }) => {
e2e.extend({
workspaceType,
@@ -33,12 +33,14 @@ e2e.describe("File Edit Auto-Approval", () => {
await inputbox.fill("edit_request")
await sidebar.getByTestId("send-button").click({ delay: 50 })
// File edits are auto-approved by default. The ask row appears with
// the file path, but no manual approval buttons are shown.
// The edit tool requires approval (edit tools are never auto-approved
// by default) — the ask row appears with the file path and diff.
await sidebar.waitForSelector('span:has-text("Cline wants to edit this file:")')
await expect(sidebar.getByText("test.ts").first()).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Reject" })).not.toBeVisible()
await expect(sidebar.getByRole("button", { name: "Save", exact: true })).not.toBeVisible()
await expect(sidebar.getByRole("button", { name: "Reject" })).toBeVisible()
// Approve the edit ("Save" is the primary button for file-edit asks).
await sidebar.getByRole("button", { name: "Save", exact: true }).click({ delay: 50 })
// The SDK executes the editor tool and sends the tool result back to
// the (mock) model, which replies with turn-ending completion text.
@@ -67,7 +67,7 @@ const HEADER_CLASSNAMES = "flex items-center gap-2.5 mb-3"
interface ChatRowProps {
message: ClineMessage
isExpanded: boolean
onToggleExpand: (ts: number, options?: { preserveAutoScroll?: boolean }) => void
onToggleExpand: (ts: number) => void
lastModifiedMessage?: ClineMessage
isLast: boolean
onHeightChange: (isTaller: boolean) => void
@@ -729,7 +729,7 @@ export const ChatRowContent = memo(
// Wait 500ms before auto-expanding to avoid animating fast commands
const timer = setTimeout(() => {
// Expand after 500ms
onToggleExpand(message.ts, { preserveAutoScroll: true })
onToggleExpand(message.ts)
}, 500)
return () => clearTimeout(timer)
@@ -747,7 +747,7 @@ export const ChatRowContent = memo(
isOutputFullyExpanded={isOutputFullyExpanded}
message={message}
onCancelCommand={onCancelCommand}
onOutputChange={onLastRowContentChange}
onOutputChange={isLast ? onLastRowContentChange : undefined}
setIsOutputFullyExpanded={setIsOutputFullyExpanded}
title={title}
/>
@@ -2,7 +2,6 @@ import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineErrorRetryMessages } from "@shared/combineErrorRetryMessages"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useMemo, useRef } from "react"
@@ -43,25 +42,6 @@ interface ChatViewProps {
const MAX_IMAGES_AND_FILES_PER_MESSAGE = CHAT_CONSTANTS.MAX_IMAGES_AND_FILES_PER_MESSAGE
const QUICK_WINS_HISTORY_THRESHOLD = 3
const sameUserMessage = (left: ClineMessage, right: ClineMessage) => {
const leftImages = left.images ?? []
const rightImages = right.images ?? []
const leftFiles = left.files ?? []
const rightFiles = right.files ?? []
return (
left.type === "say" &&
left.say === "user_feedback" &&
right.type === "say" &&
right.say === "user_feedback" &&
left.text === right.text &&
leftImages.length === rightImages.length &&
leftImages.every((image, index) => image === rightImages[index]) &&
leftFiles.length === rightFiles.length &&
leftFiles.every((file, index) => file === rightFiles[index])
)
}
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const showNavbar = useShowNavbar()
const {
@@ -78,6 +58,19 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isProdHostedApp = userInfo?.apiBaseUrl === "https://app.cline.bot"
const shouldShowQuickWins = isProdHostedApp && (!taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD)
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const modifiedMessages = useMemo(() => {
const slicedMessages = messages.slice(1)
// Only combine hook sequences if hooks are enabled
const withHooks = hooksEnabled ? combineHookSequences(slicedMessages) : slicedMessages
return combineErrorRetryMessages(combineApiRequests(combineCommandSequences(withHooks)))
}, [messages, hooksEnabled])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
const lastApiReqTotalTokens = useMemo(() => getLastApiReqTotalTokens(modifiedMessages) || undefined, [modifiedMessages])
// Use custom hooks for state management
const chatState = useChatState(messages)
const {
@@ -90,46 +83,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
enableButtons,
expandedRows,
setExpandedRows,
pendingUserMessage,
setPendingUserMessage,
textAreaRef,
} = chatState
const displayMessages = useMemo(() => {
if (
!pendingUserMessage ||
messages.some(
(message) => message.ts > pendingUserMessage.afterTs && sameUserMessage(message, pendingUserMessage.message),
)
) {
return messages
}
return [...messages, pendingUserMessage.message]
}, [messages, pendingUserMessage])
useEffect(() => {
if (
pendingUserMessage &&
messages.some(
(message) => message.ts > pendingUserMessage.afterTs && sameUserMessage(message, pendingUserMessage.message),
)
) {
setPendingUserMessage(undefined)
}
}, [messages, pendingUserMessage, setPendingUserMessage])
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const modifiedMessages = useMemo(() => {
const slicedMessages = displayMessages.slice(1)
// Only combine hook sequences if hooks are enabled
const withHooks = hooksEnabled ? combineHookSequences(slicedMessages) : slicedMessages
return combineErrorRetryMessages(combineApiRequests(combineCommandSequences(withHooks)))
}, [displayMessages, hooksEnabled])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
const lastApiReqTotalTokens = useMemo(() => getLastApiReqTotalTokens(modifiedMessages) || undefined, [modifiedMessages])
const lastAppliedCheckpointRestoreSessionId = useRef<string | undefined>(checkpointRestoreInput?.sessionId)
useEffect(() => {
@@ -359,7 +314,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [visibleMessages])
// Use scroll behavior hook
const scrollBehavior = useScrollBehavior(displayMessages, visibleMessages, groupedMessages, expandedRows, setExpandedRows)
const scrollBehavior = useScrollBehavior(messages, visibleMessages, groupedMessages, expandedRows, setExpandedRows)
const placeholderText = useMemo(() => {
const text = task ? "Type a message..." : "Type your task here..."
@@ -220,7 +220,7 @@ export const ClinePassEntitlementError: Story = {
message: createMockMessage(),
errorType: "error",
apiRequestFailedMessage:
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
},
parameters: {
docs: {
@@ -171,7 +171,7 @@ describe("ErrorRow", () => {
it("renders entitlement error when ClineError detects ClineNotSubscribedError", async () => {
const cliMessage =
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true"
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true"
const mockClineError = {
message: cliMessage,
isErrorType: vi.fn((type) => type === "entitlement"),
@@ -28,23 +28,6 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
setIsEditing(true)
}
const cancelEditing = () => {
if (savingMode) {
return
}
setIsEditing(false)
}
const handleEditingKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") {
return
}
event.preventDefault()
event.stopPropagation()
cancelEditing()
}
const handleSave = async (restoreWorkspace: boolean) => {
if (!messageTs || savingMode) {
return
@@ -112,7 +95,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
</Tooltip>
)}
{isEditing ? (
<div className="flex flex-col gap-2" onKeyDown={handleEditingKeyDown}>
<div className="flex flex-col gap-2">
<textarea
className="w-full box-border rounded-xs border border-vscode-input-border bg-vscode-input-background text-vscode-input-foreground p-2 text-sm resize-vertical"
disabled={!!savingMode}
@@ -125,13 +108,15 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
<button
className="shrink-0 whitespace-nowrap px-1 py-1 rounded-xs border-0 bg-transparent text-badge-foreground/80 hover:text-badge-foreground cursor-pointer text-xs"
disabled={!!savingMode}
onClick={cancelEditing}
onClick={() => setIsEditing(false)}
type="button">
Cancel
</button>
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipContent side="top">Rewind conversation, keep current code edits</TooltipContent>
<TooltipContent side="top">
Regenerate from this edited message without changing files.
</TooltipContent>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<button
@@ -139,14 +124,16 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
disabled={!!savingMode}
onClick={() => handleSave(false)}
type="button">
{savingMode === "chat" ? "Running..." : "Reset Chat"}
{savingMode === "chat" ? "Running..." : "Regenerate"}
</button>
</span>
</TooltipTrigger>
</Tooltip>
{canRestoreWorkspace && (
<Tooltip>
<TooltipContent side="top">Rewind conversation, reset code edits</TooltipContent>
<TooltipContent side="top">
Restore workspace files to this checkpoint, then regenerate.
</TooltipContent>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<button
@@ -154,7 +141,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
disabled={!!savingMode}
onClick={() => handleSave(true)}
type="button">
{savingMode === "workspace" ? "Restoring..." : "Reset Code"}
{savingMode === "workspace" ? "Restoring..." : "Restore + Run"}
</button>
</span>
</TooltipTrigger>
@@ -5,9 +5,8 @@
* even if you confirm the IME conversion (Enter) in message re-edit mode.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, render } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
vi.mock("@/context/ExtensionStateContext", () => ({
__esModule: true,
@@ -17,29 +16,9 @@ vi.mock("@/context/ExtensionStateContext", () => ({
}),
}))
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
editMessageAndRegenerate: vi.fn(),
},
}))
import { TaskServiceClient } from "@/services/grpc-client"
import UserMessage from "../UserMessage"
describe("UserMessage IME composition handling", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
)
vi.mocked(TaskServiceClient.editMessageAndRegenerate).mockResolvedValue({})
})
it("does NOT send when IME composition Enter is pressed while editing", () => {
const sendMessageFromChatRow = vi.fn()
@@ -61,61 +40,4 @@ describe("UserMessage IME composition handling", () => {
expect(sendMessageFromChatRow).not.toHaveBeenCalled()
})
it("cancels inline editing on Escape without bubbling to global task shortcuts", () => {
const onWindowKeyDown = vi.fn()
window.addEventListener("keydown", onWindowKeyDown)
try {
render(<UserMessage images={[]} messageTs={Date.now()} text="Original prompt" />)
fireEvent.click(screen.getByText("Original prompt"))
const textbox = screen.getByRole("textbox")
fireEvent.change(textbox, { target: { value: "Edited prompt" } })
fireEvent.keyDown(textbox, { key: "Escape" })
expect(screen.queryByRole("textbox")).not.toBeInTheDocument()
expect(screen.getByText("Original prompt")).toBeInTheDocument()
expect(onWindowKeyDown).not.toHaveBeenCalled()
} finally {
window.removeEventListener("keydown", onWindowKeyDown)
}
})
it("labels reset actions and preserves their restore behavior", async () => {
const user = userEvent.setup()
render(<UserMessage files={["src/app.ts"]} images={["image.png"]} messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
expect(screen.getByRole("button", { name: "Reset Chat" })).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Reset Code" })).toBeInTheDocument()
await user.click(screen.getByRole("button", { name: "Reset Chat" }))
await waitFor(() => expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenCalledTimes(1))
expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenLastCalledWith(
expect.objectContaining({
messageTs: 123,
text: "Update this",
images: ["image.png"],
files: ["src/app.ts"],
restoreWorkspace: false,
}),
)
await user.click(screen.getByText("Update this"))
await user.click(screen.getByRole("button", { name: "Reset Code" }))
await waitFor(() => expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenCalledTimes(2))
expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenLastCalledWith(
expect.objectContaining({
messageTs: 123,
text: "Update this",
images: ["image.png"],
files: ["src/app.ts"],
restoreWorkspace: true,
}),
)
})
})
@@ -1,8 +1,11 @@
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useRef, useState } from "react"
import { useClickAway } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAutoApproveActions } from "@/hooks/useAutoApproveActions"
import { getAsVar, VSC_TITLEBAR_INACTIVE_FOREGROUND } from "@/utils/vscStyles"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND, VSC_TITLEBAR_INACTIVE_FOREGROUND } from "@/utils/vscStyles"
import AutoApproveMenuItem from "./AutoApproveMenuItem"
import { updateAutoApproveSettings } from "./AutoApproveSettingsAPI"
import { ActionMetadata } from "./types"
const breakpoint = 500
@@ -15,6 +18,7 @@ interface AutoApproveModalProps {
}
const AutoApproveModal: React.FC<AutoApproveModalProps> = ({ isVisible, setIsVisible, buttonRef, ACTION_METADATA }) => {
const { autoApprovalSettings } = useExtensionState()
const { isChecked, updateAction } = useAutoApproveActions()
const modalRef = useRef<HTMLDivElement>(null)
const itemsContainerRef = useRef<HTMLDivElement>(null)
@@ -102,6 +106,35 @@ const AutoApproveModal: React.FC<AutoApproveModalProps> = ({ isVisible, setIsVis
<AutoApproveMenuItem action={action} isChecked={isChecked} key={action.id} onToggle={updateAction} />
))}
</div>
{/* Separator line */}
<div
style={{
height: "0.5px",
background: getAsVar(VSC_DESCRIPTION_FOREGROUND),
opacity: 0.1,
margin: "8px 0",
}}
/>
{/* Notifications toggle */}
<div className="flex items-center gap-2">
<VSCodeCheckbox
checked={autoApprovalSettings.enableNotifications}
onChange={async (e: any) => {
const checked = e.target.checked === true
await updateAutoApproveSettings({
...autoApprovalSettings,
version: (autoApprovalSettings.version ?? 1) + 1,
enableNotifications: checked,
})
}}>
<span className="text-sm">Enable notifications</span>
</VSCodeCheckbox>
</div>
<div className="mt-1 text-xs text-muted-foreground">
Notifications may show abbreviated tool details for safety and privacy.
</div>
</div>
</div>
)
@@ -1,146 +0,0 @@
import type { TurnState } from "@shared/ExtensionMessage"
import { fireEvent, render, screen } from "@testing-library/react"
import React from "react"
import { describe, expect, it, vi } from "vitest"
import type { ChatState, MessageHandlers, ScrollBehavior } from "../../types/chatTypes"
import { InputSection } from "./InputSection"
const mockTurnState = vi.fn<() => TurnState | undefined>(() => undefined)
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({ turnState: mockTurnState() }),
}))
vi.mock("@/components/chat/ChatTextArea", () => ({
default: React.forwardRef<HTMLTextAreaElement, { sendingDisabled: boolean; onSend: () => void }>(
({ sendingDisabled, onSend }, ref) => (
<textarea
aria-label="composer"
disabled={sendingDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !sendingDisabled) {
onSend()
}
}}
ref={ref}
/>
),
),
}))
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return {
activeQuote: null,
setActiveQuote: vi.fn(),
isTextAreaFocused: false,
inputValue: "queue this",
setInputValue: vi.fn(),
sendingDisabled: true,
selectedImages: [],
setSelectedImages: vi.fn(),
selectedFiles: [],
setSelectedFiles: vi.fn(),
textAreaRef: { current: null },
handleFocusChange: vi.fn(),
...overrides,
} as unknown as ChatState
}
function makeScrollBehavior(): ScrollBehavior {
return {
isAtBottom: true,
scrollToBottomAuto: vi.fn(),
} as unknown as ScrollBehavior
}
describe("InputSection", () => {
it("allows submit while the turn is streaming so the message can be queued", () => {
mockTurnState.mockReturnValue({ phase: "streaming", seq: 1 })
const handleSendMessage = vi.fn().mockResolvedValue(undefined)
render(
<InputSection
chatState={makeChatState({ sendingDisabled: true })}
messageHandlers={{ handleSendMessage } as unknown as MessageHandlers}
placeholderText="Type a message"
scrollBehavior={makeScrollBehavior()}
selectFilesAndImages={vi.fn()}
shouldDisableFilesAndImages={false}
/>,
)
const composer = screen.getByLabelText("composer")
expect(composer).not.toBeDisabled()
fireEvent.keyDown(composer, { key: "Enter" })
expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], [])
})
it("allows submit while approval is pending so typed feedback can reject the approval", () => {
mockTurnState.mockReturnValue({ phase: "awaiting_approval", seq: 1 })
const handleSendMessage = vi.fn().mockResolvedValue(undefined)
render(
<InputSection
chatState={makeChatState({ sendingDisabled: true })}
messageHandlers={{ handleSendMessage } as unknown as MessageHandlers}
placeholderText="Type a message"
scrollBehavior={makeScrollBehavior()}
selectFilesAndImages={vi.fn()}
shouldDisableFilesAndImages={false}
/>,
)
const composer = screen.getByLabelText("composer")
expect(composer).not.toBeDisabled()
fireEvent.keyDown(composer, { key: "Enter" })
expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], [])
})
it("allows submit for legacy active-task state when turnState is unavailable", () => {
mockTurnState.mockReturnValue(undefined)
const handleSendMessage = vi.fn().mockResolvedValue(undefined)
render(
<InputSection
chatState={makeChatState({
lastMessage: { ts: 1, type: "say", say: "api_req_started", partial: false },
sendingDisabled: true,
})}
messageHandlers={{ handleSendMessage } as unknown as MessageHandlers}
placeholderText="Type a message"
scrollBehavior={makeScrollBehavior()}
selectFilesAndImages={vi.fn()}
shouldDisableFilesAndImages={false}
/>,
)
const composer = screen.getByLabelText("composer")
expect(composer).not.toBeDisabled()
fireEvent.keyDown(composer, { key: "Enter" })
expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], [])
})
it("keeps submit disabled for non-active blocked states", () => {
mockTurnState.mockReturnValue({ phase: "error", seq: 1 })
const handleSendMessage = vi.fn().mockResolvedValue(undefined)
render(
<InputSection
chatState={makeChatState({ sendingDisabled: true })}
messageHandlers={{ handleSendMessage } as unknown as MessageHandlers}
placeholderText="Type a message"
scrollBehavior={makeScrollBehavior()}
selectFilesAndImages={vi.fn()}
shouldDisableFilesAndImages={false}
/>,
)
const composer = screen.getByLabelText("composer")
expect(composer).toBeDisabled()
fireEvent.keyDown(composer, { key: "Enter" })
expect(handleSendMessage).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import React from "react"
import ChatTextArea from "@/components/chat/ChatTextArea"
import QuotedMessagePreview from "@/components/chat/QuotedMessagePreview"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ChatState, MessageHandlers, ScrollBehavior } from "../../types/chatTypes"
interface InputSectionProps {
@@ -37,16 +36,9 @@ export const InputSection: React.FC<InputSectionProps> = ({
setSelectedFiles,
textAreaRef,
handleFocusChange,
lastMessage,
} = chatState
const { isAtBottom, scrollToBottomAuto } = scrollBehavior
const { turnState } = useExtensionState()
const legacyTaskRunning =
turnState === undefined &&
(lastMessage?.partial === true || (lastMessage?.type === "say" && lastMessage.say === "api_req_started"))
const allowQueuedSubmit = turnState?.phase === "streaming" || turnState?.phase === "awaiting_approval" || legacyTaskRunning
const submitDisabled = sendingDisabled && !allowQueuedSubmit
return (
<>
@@ -75,7 +67,7 @@ export const InputSection: React.FC<InputSectionProps> = ({
ref={textAreaRef}
selectedFiles={selectedFiles}
selectedImages={selectedImages}
sendingDisabled={submitDisabled}
sendingDisabled={sendingDisabled}
setInputValue={setInputValue}
setSelectedFiles={setSelectedFiles}
setSelectedImages={setSelectedImages}
@@ -1,59 +0,0 @@
import type { QueuedPrompt } from "@shared/ExtensionMessage"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { QueuedPrompts } from "./QueuedPrompts"
const cancelQueuedPromptMock = vi.hoisted(() => vi.fn())
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
cancelQueuedPrompt: (request: unknown) => cancelQueuedPromptMock(request),
},
}))
vi.mock("@shared/proto/cline/common", () => ({
StringRequest: {
create: (request: unknown) => request,
},
}))
const queuedPrompts: QueuedPrompt[] = [
{
id: "prompt-1",
prompt: "First queued message",
delivery: "queue",
attachmentCount: 0,
},
{
id: "prompt-2",
prompt: "Second queued message",
delivery: "steer",
attachmentCount: 1,
},
]
describe("QueuedPrompts", () => {
beforeEach(() => {
cancelQueuedPromptMock.mockReset()
cancelQueuedPromptMock.mockResolvedValue({})
})
it("cancels a queued prompt from the row action", async () => {
render(<QueuedPrompts items={queuedPrompts} />)
const cancelButtons = screen.getAllByRole("button", { name: "Cancel queued message" })
fireEvent.click(cancelButtons[0])
expect(cancelQueuedPromptMock).toHaveBeenCalledTimes(1)
expect(cancelQueuedPromptMock).toHaveBeenCalledWith({ value: "prompt-1" })
expect(cancelButtons[0]).toBeDisabled()
await waitFor(() => expect(cancelButtons[0]).not.toBeDisabled())
})
it("does not render an empty queue", () => {
const { container } = render(<QueuedPrompts items={[]} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -1,7 +1,4 @@
import type { QueuedPrompt } from "@shared/ExtensionMessage"
import { StringRequest } from "@shared/proto/cline/common"
import { useState } from "react"
import { TaskServiceClient } from "@/services/grpc-client"
function truncatePrompt(prompt: string): string {
const trimmed = prompt.trim()
@@ -32,63 +29,29 @@ interface QueuedPromptsProps {
}
export function QueuedPrompts({ items = [] }: QueuedPromptsProps) {
const [cancellingIds, setCancellingIds] = useState<Set<string>>(() => new Set())
if (items.length === 0) {
return null
}
const cancelQueuedPrompt = (promptId: string) => {
setCancellingIds((current) => new Set(current).add(promptId))
TaskServiceClient.cancelQueuedPrompt(StringRequest.create({ value: promptId }))
.catch((error) => {
console.error("Failed to cancel queued prompt:", error)
})
.finally(() => {
setCancellingIds((current) => {
const next = new Set(current)
next.delete(promptId)
return next
})
})
}
return (
<div className="mx-3 mt-2.5 mb-2.5 rounded-xs border border-editor-group-border bg-code/70 px-2.5 py-2 shadow-xs">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-description">
<span aria-hidden="true" className="codicon codicon-clock text-[12px]" />
<div className="mx-3 mb-2 rounded-xs border border-editor-group-border bg-code px-2 py-1.5">
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-description">
<span className="codicon codicon-clock text-[12px]" />
<span>{queueSummary(items)}</span>
</div>
<div className="flex max-h-28 flex-col gap-1.5 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<div className="flex max-h-24 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const attachments = attachmentLabel(item.attachmentCount)
const isSteer = item.delivery === "steer"
const isCancelling = cancellingIds.has(item.id)
return (
<div
className="flex items-start gap-2 rounded-[3px] bg-input-background/40 px-2 py-1.5 text-xs leading-snug"
key={item.id}>
<span aria-hidden="true" className="mt-[5px] size-1.5 shrink-0 rounded-full bg-description/70" />
<div className="flex items-start gap-1.5 text-xs leading-snug" key={item.id}>
<span
className={`codicon ${isSteer ? "codicon-debug-continue" : "codicon-chevron-right"} mt-[1px] shrink-0 text-[11px] text-description`}
title={isSteer ? "Steering message" : "Queued message"}
/>
<span className="min-w-0 flex-1 break-words text-foreground">{truncatePrompt(item.prompt)}</span>
{isSteer && (
<span className="shrink-0 rounded-[3px] border border-editor-group-border px-1.5 py-[1px] text-[10px] leading-4 text-description">
Steer
</span>
)}
{attachments && (
<span className="shrink-0 rounded-[3px] border border-editor-group-border px-1.5 py-[1px] text-[10px] leading-4 text-description">
{attachments}
</span>
)}
<button
aria-label="Cancel queued message"
className="mt-[-2px] flex size-5 shrink-0 items-center justify-center rounded-[3px] text-description hover:bg-toolbar-hover-background hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
disabled={isCancelling}
onClick={() => cancelQueuedPrompt(item.id)}
title="Cancel queued message"
type="button">
<span aria-hidden="true" className="codicon codicon-close text-[12px]" />
</button>
{isSteer && <span className="shrink-0 text-description">Steer</span>}
{attachments && <span className="shrink-0 text-description">{attachments}</span>}
</div>
)
})}
@@ -15,7 +15,7 @@ interface MessageRendererProps {
groupedMessages: (ClineMessage | ClineMessage[])[]
modifiedMessages: ClineMessage[]
expandedRows: Record<number, boolean>
onToggleExpand: (ts: number, options?: { preserveAutoScroll?: boolean }) => void
onToggleExpand: (ts: number) => void
onHeightChange: (isTaller: boolean) => void
onLastRowContentChange: () => void
onSetQuote: (quote: string | null) => void
@@ -136,7 +136,7 @@ export const createMessageRenderer = (
groupedMessages: (ClineMessage | ClineMessage[])[],
modifiedMessages: ClineMessage[],
expandedRows: Record<number, boolean>,
onToggleExpand: (ts: number, options?: { preserveAutoScroll?: boolean }) => void,
onToggleExpand: (ts: number) => void,
onHeightChange: (isTaller: boolean) => void,
onLastRowContentChange: () => void,
onSetQuote: (quote: string | null) => void,
@@ -1,6 +1,6 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ChatState, PendingUserMessage } from "../types/chatTypes"
import { ChatState } from "../types/chatTypes"
/**
* Custom hook for managing chat state
@@ -20,7 +20,6 @@ export function useChatState(messages: ClineMessage[]): ChatState {
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>("Approve")
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>("Reject")
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const [pendingUserMessage, setPendingUserMessage] = useState<PendingUserMessage | undefined>(undefined)
// Refs
const textAreaRef = useRef<HTMLTextAreaElement>(null)
@@ -76,8 +75,6 @@ export function useChatState(messages: ClineMessage[]): ChatState {
setSecondaryButtonText,
expandedRows,
setExpandedRows,
pendingUserMessage,
setPendingUserMessage,
// Refs
textAreaRef,
@@ -66,8 +66,6 @@ function makeChatState(messages: ClineMessage[], overrides: Partial<ChatState> =
setSecondaryButtonText: vi.fn(),
expandedRows: {},
setExpandedRows: vi.fn(),
pendingUserMessage: undefined,
setPendingUserMessage: vi.fn(),
textAreaRef: { current: null },
lastMessage: last,
secondLastMessage: messages.at(-2),
@@ -165,7 +163,6 @@ describe("useMessageHandlers — send routing", () => {
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const setPendingUserMessage = vi.fn()
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
@@ -176,7 +173,6 @@ describe("useMessageHandlers — send routing", () => {
setSelectedImages,
setSelectedFiles,
setEnableButtons,
setPendingUserMessage,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState))
@@ -200,19 +196,6 @@ describe("useMessageHandlers — send routing", () => {
expect(setSelectedImages).toHaveBeenCalledWith([])
expect(setSelectedFiles).toHaveBeenCalledWith([])
expect(setEnableButtons).toHaveBeenCalledWith(false)
expect(setPendingUserMessage).toHaveBeenCalledWith(
expect.objectContaining({
afterTs: 2,
message: expect.objectContaining({
type: "say",
say: "user_feedback",
text: expect.stringContaining("another question"),
images: ["image.png"],
files: ["a.ts"],
partial: false,
}),
}),
)
await act(async () => {
resolveAskResponse()
@@ -229,7 +212,6 @@ describe("useMessageHandlers — send routing", () => {
const setSelectedImages = vi.fn()
const setSelectedFiles = vi.fn()
const setEnableButtons = vi.fn()
const setPendingUserMessage = vi.fn()
const chatState = makeChatState(completedConversation, {
activeQuote: "selected context",
sendingDisabled: false,
@@ -240,7 +222,6 @@ describe("useMessageHandlers — send routing", () => {
setSelectedImages,
setSelectedFiles,
setEnableButtons,
setPendingUserMessage,
})
const { result } = renderHook(() => useMessageHandlers(completedConversation, chatState))
askResponse.mockRejectedValueOnce(error)
@@ -267,67 +248,6 @@ describe("useMessageHandlers — send routing", () => {
expect(setSelectedFiles).toHaveBeenLastCalledWith(["a.ts"])
expect(setEnableButtons).toHaveBeenNthCalledWith(1, false)
expect(setEnableButtons).toHaveBeenLastCalledWith(true)
expect(setPendingUserMessage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
afterTs: 2,
message: expect.objectContaining({
type: "say",
say: "user_feedback",
text: expect.stringContaining("another question"),
}),
}),
)
expect(setPendingUserMessage).toHaveBeenLastCalledWith(undefined)
})
it("does not show a pending chat bubble for a streaming follow-up that will be queued", async () => {
mockTurnState = { phase: "streaming", seq: 9 }
const streamingConversation: ClineMessage[] = [
{ ts: 1, type: "say", say: "task", text: "task" },
{ ts: 2, type: "say", say: "text", text: "working", partial: true },
]
const setPendingUserMessage = vi.fn()
const { result } = renderHook(() =>
useMessageHandlers(streamingConversation, makeChatState(streamingConversation, { setPendingUserMessage })),
)
await act(async () => {
await result.current.handleSendMessage("steer this way", [], [])
})
expect(askResponse).toHaveBeenCalledTimes(1)
expect(setPendingUserMessage).not.toHaveBeenCalled()
})
it("rejects a pending approval when the composer is submitted with typed feedback", async () => {
mockTurnState = { phase: "awaiting_approval", anchorTs: 2, seq: 9 }
const approvalConversation: ClineMessage[] = [
{ ts: 1, type: "say", say: "task", text: "task" },
{ ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ tool: "newFileCreated", path: "notes.txt" }) },
]
const setPendingUserMessage = vi.fn()
const { result } = renderHook(() =>
useMessageHandlers(approvalConversation, makeChatState(approvalConversation, { setPendingUserMessage })),
)
await act(async () => {
await result.current.handleSendMessage("use a different filename", ["image.png"], ["notes.txt"])
})
expect(newTask).not.toHaveBeenCalled()
expect(condense).not.toHaveBeenCalled()
expect(askResponse).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "noButtonClicked",
text: "use a different filename",
images: ["image.png"],
files: ["notes.txt"],
}),
)
expect(askResponse).not.toHaveBeenCalledWith(expect.objectContaining({ responseType: "messageResponse" }))
expect(setPendingUserMessage).not.toHaveBeenCalled()
})
it("phase awaiting_followup also routes a follow-up to askResponse", async () => {
@@ -342,33 +262,6 @@ describe("useMessageHandlers — send routing", () => {
expect(askResponse).toHaveBeenCalledTimes(1)
})
it("does not show a pending chat bubble when answering an active follow-up question with freeform text", async () => {
mockTurnState = { phase: "awaiting_followup", anchorTs: 2, seq: 3 }
const questionConversation: ClineMessage[] = [
{ ts: 1, type: "say", say: "task", text: "task" },
{
ts: 2,
type: "ask",
ask: "followup",
text: JSON.stringify({ question: "Which approach?", options: ["A", "B"] }),
},
]
const setPendingUserMessage = vi.fn()
const { result } = renderHook(() =>
useMessageHandlers(questionConversation, makeChatState(questionConversation, { setPendingUserMessage })),
)
await act(async () => {
await result.current.handleSendMessage("something else", [], [])
})
expect(askResponse).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({ responseType: "messageResponse", text: "something else" }),
)
expect(setPendingUserMessage).not.toHaveBeenCalled()
})
it("an empty transcript still starts a NEW task (unchanged behavior)", async () => {
mockTurnState = { phase: "idle", seq: 1 }
const { result } = renderHook(() => useMessageHandlers([], makeChatState([])))
@@ -23,7 +23,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
setSendingDisabled,
enableButtons,
setEnableButtons,
setPendingUserMessage,
clineAsk,
lastMessage,
} = chatState
@@ -80,32 +79,11 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
setSelectedFiles(files)
setEnableButtons(enableButtons)
}
const sendAskResponseWithPendingState = async (
request: ReturnType<typeof AskResponseRequest.create>,
options: { showPendingMessage?: boolean } = {},
) => {
const sendAskResponseWithPendingState = async (request: ReturnType<typeof AskResponseRequest.create>) => {
clearSentMessageState()
if (options.showPendingMessage) {
const afterTs = Math.max(0, ...messages.map((message) => message.ts))
setPendingUserMessage({
afterTs,
message: {
ts: Date.now(),
type: "say",
say: "user_feedback",
text: request.text ?? "",
images: request.images,
files: request.files,
partial: false,
},
})
}
try {
await TaskServiceClient.askResponse(request)
} catch (error) {
if (options.showPendingMessage) {
setPendingUserMessage(undefined)
}
restorePendingMessageState()
throw error
}
@@ -125,16 +103,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
throw error
}
messageSent = true
} else if (turnState?.phase === "awaiting_approval") {
await sendAskResponseWithPendingState(
AskResponseRequest.create({
responseType: "noButtonClicked",
text: messageToSend,
images,
files,
}),
)
messageSent = true
} else if (clineAsk) {
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
// This ensures Enter key and Resume button work identically
@@ -164,16 +132,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
case "api_req_failed":
case "new_task":
case "condense":
case "report_bug": {
// Most askResponse sends need a temporary webview-only user bubble because the
// extension will not echo the user's message until later. Active follow-up
// questions are the exception: they are backed by the SDK's pending ask_question
// resolver. When the user types a freeform answer instead of clicking one of the
// option buttons, that resolver consumes the response before normal follow-up
// routing and immediately appends the real say:user_feedback row. If we also add
// an optimistic pending row here, the chat shows the same answer twice.
const showPendingMessage = clineAsk !== "followup" && turnState?.phase !== "streaming"
case "report_bug":
await sendAskResponseWithPendingState(
AskResponseRequest.create({
responseType: "messageResponse",
@@ -181,11 +140,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
images,
files,
}),
{ showPendingMessage },
)
messageSent = true
break
}
}
}
} else if (messages.length > 0) {
@@ -217,9 +174,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
images,
files,
}),
{
showPendingMessage: turnState?.phase === "completed" || turnState?.phase === "awaiting_followup",
},
)
messageSent = true
}
@@ -249,7 +203,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
setSelectedFiles,
enableButtons,
setEnableButtons,
setPendingUserMessage,
chatState,
],
)

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