mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2263078684 | |||
| a3989acc38 | |||
| d199b1bff9 | |||
| d41eed1198 | |||
| 6309971089 | |||
| 2d2c669421 | |||
| c5f146a418 | |||
| 261ee4c313 | |||
| d45b051c04 | |||
| 78c83cdf33 | |||
| 3266121fa1 | |||
| 6467de65a2 | |||
| 65fe885638 | |||
| c3033d6f13 | |||
| cfb1327a1b | |||
| 264af96e1b | |||
| 10cb9bd97a | |||
| 2ee18e7f0c | |||
| 0b65506a2b | |||
| 3502608081 | |||
| ed3107f9ec | |||
| 6bce48aad4 | |||
| 1e1b6af51c | |||
| ee49900232 | |||
| a1d5589d19 | |||
| 721fda2e99 | |||
| 5e78861eb5 | |||
| 29798f59f3 | |||
| 177d0eb07f | |||
| 869a87a220 | |||
| 0cfd0bbe05 | |||
| 08f656532f | |||
| 10dece6677 | |||
| 885a2936b6 |
@@ -39,14 +39,6 @@
|
||||
"sdk/packages/core/src/auth/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-telemetry-doc-update",
|
||||
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
|
||||
"scope": [
|
||||
"sdk/packages/core/src/services/telemetry/core-events.ts"
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,13 +16,9 @@
|
||||
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
|
||||
},
|
||||
{
|
||||
"path": "DOC.md",
|
||||
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
|
||||
},
|
||||
{
|
||||
"path": "sdk/ARCHITECTURE.md",
|
||||
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "sdk/AGENTS.md",
|
||||
|
||||
+20
-17
@@ -36,8 +36,11 @@ event names. It exports:
|
||||
|
||||
1. Add the constant to `CORE_TELEMETRY_EVENTS`
|
||||
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
|
||||
3. Update the Event Catalog section in `DOC.md`
|
||||
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
|
||||
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
|
||||
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
|
||||
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
@@ -82,7 +85,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
|
||||
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
|
||||
config dir.
|
||||
|
||||
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
|
||||
The canonical pattern is in `apps/cli/src/main.ts`:
|
||||
|
||||
```ts
|
||||
if (configDir) setClineDir(configDir);
|
||||
@@ -90,18 +93,18 @@ setHomeDir(homedir());
|
||||
captureCliExtensionActivated(); // <-- after dir overrides
|
||||
```
|
||||
|
||||
## Hub Daemon Metadata Forwarding
|
||||
## Hub Daemon Telemetry
|
||||
|
||||
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
|
||||
metadata into the daemon argv so the daemon can reconstruct an equivalent
|
||||
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
|
||||
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
|
||||
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
|
||||
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
|
||||
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
|
||||
```
|
||||
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
|
||||
```
|
||||
|
||||
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
|
||||
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
@@ -120,10 +123,10 @@ canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
|
||||
On VS Code, the telemetry handle is built **once** in `activate()`
|
||||
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
|
||||
command, and daemon spawn payload. Do not let individual controllers construct their own
|
||||
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
On VS Code, all callers go through the lazy `telemetryService` proxy in
|
||||
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
|
||||
use. Do not let individual controllers construct their own `ITelemetryService` — that
|
||||
fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
|
||||
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
|
||||
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
|
||||
|
||||
Vendored
+7
-7
@@ -68,7 +68,7 @@
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -89,7 +89,7 @@
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -114,16 +114,16 @@
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
"message": 1
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.39
|
||||
|
||||
- You can now select Cline free models on the ClinePass provider in the model picker
|
||||
- Removed the retired ClinePass GLM 5.1 model
|
||||
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
|
||||
- `str_replace` edits now report accurate diffs
|
||||
- Fixed context compaction so canonical session history is preserved
|
||||
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
|
||||
- Cline provider requests now send versioned client-identity headers
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.38",
|
||||
"version": "3.0.39",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -511,6 +511,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
const cwd = session.cwd || process.cwd();
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
// Resolve credentials: env vars take precedence, then session provider.
|
||||
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
|
||||
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
@@ -519,6 +520,7 @@ export class AcpAgent implements Agent {
|
||||
providerId,
|
||||
mode: session.currentMode,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
|
||||
return {
|
||||
providerId,
|
||||
@@ -537,7 +539,23 @@ export class AcpAgent implements Agent {
|
||||
enableAgentTeams: false,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot: resolveWorkspaceRoot(cwd),
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: {
|
||||
name: "cline-acp",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
workspaceName: cwd,
|
||||
ide: "Terminal Shell",
|
||||
platform: process.platform,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,22 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
@@ -118,7 +118,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
|
||||
// them to ~/.bun/install/global/node_modules/..., so match both.
|
||||
if (
|
||||
scriptPath.includes("/.bun/bin") ||
|
||||
scriptPath.includes("/.bun/install/global/")
|
||||
) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
|
||||
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
});
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
|
||||
+22
-3
@@ -15,6 +15,7 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
@@ -925,7 +926,18 @@ export async function runCli(): Promise<void> {
|
||||
coreServer: { createUserInstructionConfigService },
|
||||
resolveSystemPrompt,
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// Register the SDK early logger as early as possible — before any
|
||||
// provider settings reads — so the full startup sequence is captured.
|
||||
// These components operate before/outside ClineCore sessions, so the
|
||||
// session-scoped logger can't reach them.
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "main",
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
@@ -1043,6 +1055,7 @@ export async function runCli(): Promise<void> {
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -1052,7 +1065,7 @@ export async function runCli(): Promise<void> {
|
||||
interactive: args.interactive === true,
|
||||
hasPrompt: !!args.prompt?.trim(),
|
||||
cwd,
|
||||
});
|
||||
});
|
||||
|
||||
const config: Config = {
|
||||
providerId: provider,
|
||||
@@ -1093,7 +1106,13 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: { name: "cline-cli" },
|
||||
client: {
|
||||
name: "cline-cli",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
|
||||
@@ -157,6 +157,7 @@ function makeManager() {
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
updateSessionConnection: vi.fn(async () => {}),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -814,6 +815,83 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
|
||||
const manager = makeManager();
|
||||
const config = {
|
||||
...createConfig(),
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
config.providerId = "openai-compatible";
|
||||
config.modelId = "custom-model";
|
||||
config.apiKey = "new-key";
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
}),
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the active session connection in place without restarting", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.updateCurrentSessionConnection({
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
|
||||
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
});
|
||||
|
||||
it("does not reuse the session id when restarting empty", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
const secondStart = manager.start.mock.calls[1]?.[0] as {
|
||||
config?: { sessionId?: string };
|
||||
};
|
||||
expect(secondStart?.config?.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
|
||||
@@ -49,6 +49,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
|
||||
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
|
||||
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
|
||||
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
export type SessionConnectionUpdate = Parameters<
|
||||
CliCore["updateSessionConnection"]
|
||||
>[1];
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
@@ -210,12 +213,18 @@ export function createInteractiveSessionRuntime(input: {
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
// For continuing the same conversation, e.g. after a config change.
|
||||
sessionId?: string,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: buildSessionConfig(),
|
||||
config: {
|
||||
...buildSessionConfig(),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
@@ -411,43 +420,51 @@ export function createInteractiveSessionRuntime(input: {
|
||||
});
|
||||
};
|
||||
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
reuseSessionId,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const [{ messages, status }, compactionState] = await Promise.all([
|
||||
@@ -473,9 +490,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
{ preserveSessionId: true },
|
||||
);
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
// No live session to update; the next startup builds its config from
|
||||
// the already-mutated CLI config, so nothing else is needed.
|
||||
return;
|
||||
}
|
||||
await manager.updateSessionConnection(sessionId, update);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
await restartWithMessages([]);
|
||||
};
|
||||
@@ -840,6 +872,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
compactCurrentSession,
|
||||
|
||||
@@ -43,6 +43,15 @@ const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
"ClinePass limit reached",
|
||||
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
].join("\n");
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
extractClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
const prefix = "you have reached your";
|
||||
const suffix = "please try again later.";
|
||||
const start = normalized.indexOf(prefix);
|
||||
if (start === -1) return undefined;
|
||||
const suffixStart = normalized.indexOf(suffix, start);
|
||||
if (suffixStart === -1) return undefined;
|
||||
const end = suffixStart + suffix.length;
|
||||
if (!normalized.slice(start, end).includes("clinepass limit")) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start, end);
|
||||
},
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -769,6 +802,126 @@ describe("runAgent", () => {
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_LIMIT_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
resolveReasoningForModelChange,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModelChange", () => {
|
||||
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
} as Config;
|
||||
const getProviderSettings = vi.fn(() => ({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible" as const,
|
||||
protocol: "openai-chat" as const,
|
||||
model: "old-model",
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
const updateCurrentSessionConnection = vi.fn(async () => {});
|
||||
|
||||
await applyInteractiveModelChange({
|
||||
config,
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
},
|
||||
sessionRuntime: {
|
||||
ensureReady,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible",
|
||||
protocol: "openai-chat",
|
||||
model: "custom-model",
|
||||
});
|
||||
expect(ensureReady).toHaveBeenCalledOnce();
|
||||
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
|
||||
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
});
|
||||
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function applyInteractiveModelChange(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<
|
||||
ProviderSettingsManager,
|
||||
"getProviderSettings" | "saveProviderSettings"
|
||||
>;
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
| "ensureReady"
|
||||
| "restartWithCurrentMessages"
|
||||
| "updateCurrentSessionConnection"
|
||||
>;
|
||||
}): Promise<void> {
|
||||
const { config, providerSettingsManager, sessionRuntime } = input;
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
|
||||
// Provider changes affect more than the model connection: startup resolves
|
||||
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
|
||||
// the runtime with the existing transcript so all of that state changes
|
||||
// together. restartWithCurrentMessages preserves the session ID.
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
// A same-ID restart reuses the existing manifest. Sync its connection label
|
||||
// after the fully configured runtime is live so session history reflects the
|
||||
// provider/model that will handle subsequent turns.
|
||||
await sessionRuntime.updateCurrentSessionConnection({
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -687,25 +732,12 @@ export async function runInteractive(
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
onModelChange: () =>
|
||||
applyInteractiveModelChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
providerSettingsManager,
|
||||
sessionRuntime,
|
||||
}),
|
||||
onSessionRestart: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.restartEmpty();
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -419,6 +421,54 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Switch to Cline usage-based billing and retry with the Cline provider."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Interactive CLI: </text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="type /model, press tab to change provider, choose Cline, then retry."
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Headless CLI: </text>
|
||||
<text fg={props.defaultFg} selectable content="rerun with " />
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -534,6 +584,15 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: ClineModelPickerTier;
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
|
||||
ClineModelPickerTier,
|
||||
string
|
||||
> = {
|
||||
recommended: "Recommended",
|
||||
subscribed: "Subscribed",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
// Featured entries for the sectioned picker, keyed by provider: cline gets
|
||||
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
|
||||
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
|
||||
export function buildFeaturedModelEntries(
|
||||
providerId: string,
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
return providerId === "cline-pass"
|
||||
? buildClinePassModelEntries(data)
|
||||
: buildClineModelEntries(data);
|
||||
}
|
||||
|
||||
function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Shown under the Free section header when picking a model for ClinePass
|
||||
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
|
||||
"Try with limited usage, separate from ClinePass quota.";
|
||||
|
||||
// ClinePass shows the subscription's models plus the Cline free models — both
|
||||
// providers hit the same Cline API, so free models are selectable in place
|
||||
// (they ride usage billing at $0 instead of the subscription quota).
|
||||
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
|
||||
// the ClinePass catalog contains exactly these two buckets, so the sections
|
||||
// already list every selectable model. An empty clinePass bucket means the
|
||||
// fetch fell back to the bundled list (which has no pass models) — without an
|
||||
// escape into the full catalog a subscriber could only pick free models, so
|
||||
// browse-all comes back in that degraded mode.
|
||||
function buildClinePassModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.clinePass) {
|
||||
entries.push({ kind: "model", model: m, tier: "subscribed" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
if (data.clinePass.length === 0) {
|
||||
entries.push({ kind: "browse" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The quota explainer only makes sense in the ClinePass picker, which is the
|
||||
// only picker that has a "subscribed" section
|
||||
export function freeTierDescriptionFor(
|
||||
entries: ClineModelPickerEntry[],
|
||||
): string | undefined {
|
||||
const isClinePassPicker = entries.some(
|
||||
(entry) => entry.kind === "model" && entry.tier === "subscribed",
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
|
||||
describe("cline model picker entries", () => {
|
||||
it("builds Recommended/Free sections for the cline provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("anthropic/claude-sonnet-5"),
|
||||
tier: "recommended",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds Subscribed/Free sections for the cline-pass provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
|
||||
{
|
||||
kind: "model",
|
||||
model: model("cline-pass/kimi-k2.6"),
|
||||
tier: "subscribed",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds the browse-all escape when the clinePass bucket is empty", () => {
|
||||
// The fetch fell back to the bundled list (no pass models); the sections
|
||||
// alone would leave a subscriber able to pick only free models.
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
|
||||
const data = {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
|
||||
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
@@ -9,20 +8,23 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
export {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerBrowse,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
@@ -39,12 +41,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
@@ -68,20 +71,6 @@ export function useClineRecommendedModels() {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function ClineModelPicker(props: {
|
||||
entries: ClineModelPickerEntry[];
|
||||
selected: number;
|
||||
@@ -103,6 +92,7 @@ export function ClineModelPicker(props: {
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const rows: ReactNode[] = [];
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -112,14 +102,20 @@ export function ClineModelPicker(props: {
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
|
||||
rows.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
{entry.tier === "free" && freeTierDescription && (
|
||||
<text fg="gray">
|
||||
<em>{freeTierDescription}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
@@ -29,12 +34,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
@@ -62,11 +68,13 @@ export function ClineModelSelectorContent(
|
||||
key: string;
|
||||
kind: "header" | "model" | "browse";
|
||||
label: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}[] = [];
|
||||
let lastTier: string | null = null;
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
@@ -76,7 +84,9 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
|
||||
description:
|
||||
entry.tier === "free" ? freeTierDescription : undefined,
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
@@ -156,8 +166,18 @@ export function ClineModelSelectorContent(
|
||||
if (row.kind === "header") {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
marginTop={isFirst ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{row.label}</text>
|
||||
{row.description && (
|
||||
<text fg="gray">
|
||||
<em>{row.description}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
ProviderPickerContent,
|
||||
UseExistingOrReconfigureContent,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
@@ -341,7 +341,13 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.providerId === "cline") {
|
||||
if (
|
||||
config.providerId === "cline" ||
|
||||
config.providerId === "cline-pass"
|
||||
) {
|
||||
// ClinePass gets the same sectioned picker with Subscribed/Free
|
||||
// sections — free models are selectable while staying on ClinePass
|
||||
const featuredProviderId = config.providerId;
|
||||
const clineResult = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -351,7 +357,10 @@ export function useModelSelector(opts: {
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
buildFeaturedModelEntries(
|
||||
featuredProviderId,
|
||||
await fetchClineRecommendedModels(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
loadIndividualSubscriptionPlansFromProviderSettings,
|
||||
} from "../../cline-account";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildFeaturedModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
@@ -206,11 +206,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
|
||||
// Cline featured model picker
|
||||
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
|
||||
const recommended = useClineRecommendedModels();
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
() =>
|
||||
recommended.data
|
||||
? buildFeaturedModelEntries(activeProviderId, recommended.data)
|
||||
: [],
|
||||
[recommended.data, activeProviderId],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
@@ -221,20 +224,37 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLocalProviderModels("cline")
|
||||
.then(({ models }) => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of models) {
|
||||
// The featured picker serves both cline and cline-pass, so pool reasoning
|
||||
// support and display names from both catalogs
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
getLocalProviderModels(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const ids = new Set<string>();
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
for (const m of result.value.models) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
})
|
||||
.catch(() => {});
|
||||
resolveProviderConfig("cline")
|
||||
.then((resolved) => {
|
||||
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
});
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
resolveProviderConfig(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value?.knownModels) {
|
||||
Object.assign(merged, result.value.knownModels);
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged).length > 0) {
|
||||
setClineKnownModels(merged);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
|
||||
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,5 +207,6 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
// ClinePass uses the featured picker too, with Subscribed/Free sections
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const detail =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
|
||||
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClinePassLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Switch to Cline usage-based billing",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
@@ -24,6 +27,18 @@ export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getCliClinePassLimitMessage(message: string): string {
|
||||
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
|
||||
const lines = [
|
||||
"ClinePass limit reached",
|
||||
detail,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
];
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
);
|
||||
}
|
||||
|
||||
export function getClinePassLimitDetailMessage(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
return extractClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClinePassLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClinePassLimitError" ||
|
||||
isClinePassLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,64 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
describe("resolveStatusNoticeLabel", () => {
|
||||
it("maps compaction status reasons to stable labels", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "auto-compacting",
|
||||
reason: "auto_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("auto-compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "manual",
|
||||
reason: "manual_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction-budget-adjusted",
|
||||
reason: "compaction_budget_emergency",
|
||||
} as AgentEvent),
|
||||
).toBe("context budget adjusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errorOutput = "";
|
||||
setCurrentOutputMode("text");
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
errorOutput += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
errorOutput += `${args.map(String).join(" ")}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a ⎿ before text that follows a tool block", () => {
|
||||
@@ -160,6 +205,23 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toContain("── aborted (2 iterations) ──");
|
||||
});
|
||||
|
||||
it("formats ClinePass limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("ClinePass limit reached");
|
||||
expect(errorOutput).toContain("Switch to Cline usage-based billing");
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -27,8 +28,13 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
switch (event.reason) {
|
||||
case "auto_compaction":
|
||||
return "auto-compacting";
|
||||
case "manual_compaction":
|
||||
return "compacting";
|
||||
case "compaction_budget_emergency":
|
||||
return "context budget adjusted";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
@@ -176,7 +182,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(event.error.message);
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -53,6 +53,37 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("zeros cost of free models selected on the cline-pass provider", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// subscription (cline-pass/...) models are not in the free bucket
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -73,7 +73,9 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
// Free models are also selectable on ClinePass — they ride usage billing at $0
|
||||
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
|
||||
return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
@@ -179,58 +180,43 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
const updates: SessionConnectionUpdate = {};
|
||||
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
|
||||
// into typed fields; the thinking/reasoning transition rules live in the
|
||||
// shared @cline/core builder.
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
if (providerId) {
|
||||
updates.providerId = providerId;
|
||||
}
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
if (modelId) {
|
||||
updates.modelId = modelId;
|
||||
}
|
||||
const apiKey =
|
||||
const rawApiKey =
|
||||
typeof config.apiKey === "string"
|
||||
? config.apiKey.trim()
|
||||
: typeof config.api_key === "string"
|
||||
? config.api_key.trim()
|
||||
: undefined;
|
||||
if (apiKey) {
|
||||
updates.apiKey = apiKey;
|
||||
}
|
||||
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
|
||||
updates.baseUrl = config.baseUrl.trim();
|
||||
}
|
||||
if (config.headers && typeof config.headers === "object") {
|
||||
updates.headers = config.headers as Record<string, string>;
|
||||
}
|
||||
if (config.providerConfig && typeof config.providerConfig === "object") {
|
||||
updates.providerConfig =
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"];
|
||||
}
|
||||
if (thinking === false) {
|
||||
updates.thinking = false;
|
||||
updates.reasoningEffort = null;
|
||||
updates.thinkingBudgetTokens = null;
|
||||
return updates;
|
||||
}
|
||||
if (thinking === true) {
|
||||
updates.thinking = true;
|
||||
}
|
||||
if (reasoningEffort) {
|
||||
updates.thinking = true;
|
||||
updates.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
if (thinkingBudgetTokens !== undefined) {
|
||||
updates.thinking = true;
|
||||
updates.thinkingBudgetTokens = thinkingBudgetTokens;
|
||||
}
|
||||
return updates;
|
||||
const baseUrl =
|
||||
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return buildConnectionUpdate({
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(rawApiKey ? { apiKey: rawApiKey } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(config.headers && typeof config.headers === "object"
|
||||
? { headers: config.headers as Record<string, string> }
|
||||
: {}),
|
||||
...(config.providerConfig && typeof config.providerConfig === "object"
|
||||
? {
|
||||
providerConfig:
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"],
|
||||
}
|
||||
: {}),
|
||||
...(typeof config.thinking === "boolean"
|
||||
? { thinking: config.thinking }
|
||||
: {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
|
||||
@@ -694,7 +694,9 @@ export async function initializeSessionManager(
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(`code-sidecar:${process.pid}:${randomUUID()}`),
|
||||
owner: resolveHubOwnerContext(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "./context";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { BunRuntime, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -59,8 +59,10 @@ async function main() {
|
||||
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const wsEndpoint = `ws://127.0.0.1:${port}/transport`;
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_HOST,
|
||||
SIDECAR_MODE,
|
||||
SIDECAR_PORT,
|
||||
type SidecarContext,
|
||||
@@ -15,12 +16,20 @@ type SidecarServer = {
|
||||
upgrade(req: Request): boolean;
|
||||
};
|
||||
|
||||
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
|
||||
// the sidecar runs inside a container). Origin validation itself stays on.
|
||||
const EXTRA_TRUSTED_ORIGINS = (process.env.CLINE_SIDECAR_TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const TRUSTED_BROWSER_ORIGINS = new Set([
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://localhost:3125",
|
||||
"http://127.0.0.1:3125",
|
||||
...EXTRA_TRUSTED_ORIGINS,
|
||||
]);
|
||||
|
||||
const JSON_HEADERS = {
|
||||
@@ -115,7 +124,7 @@ export function startServer(
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
server = BunRuntime.serve({
|
||||
hostname: "127.0.0.1",
|
||||
hostname: SIDECAR_HOST,
|
||||
port: candidate,
|
||||
fetch: createFetchHandler(ctx, onShutdown),
|
||||
websocket: createWebSocketHandler(ctx),
|
||||
|
||||
@@ -115,4 +115,8 @@ export type BunRuntimeApi = {
|
||||
export const BunRuntime = (globalThis as { Bun?: BunRuntimeApi }).Bun;
|
||||
|
||||
export const SIDECAR_PORT = Number(process.env.CLINE_SIDECAR_PORT) || 3126;
|
||||
// Loopback-only by default. Set CLINE_SIDECAR_HOST=0.0.0.0 to accept
|
||||
// connections from outside the local host (e.g. Docker port publishing).
|
||||
export const SIDECAR_HOST =
|
||||
process.env.CLINE_SIDECAR_HOST?.trim() || "127.0.0.1";
|
||||
export const SIDECAR_MODE = "sidecar";
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
@import "@fontsource-variable/geist";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-geist-sans: "Geist Variable";
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
--font-desktop-sans: "Schibsted Grotesk Variable";
|
||||
--font-desktop-mono: "Azeret Mono";
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--primary: oklch(0.75 0.12 165);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
@@ -45,42 +44,102 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.75 0.12 165);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), "Geist Fallback";
|
||||
--font-mono: var(--font-geist-mono), "Geist Mono Fallback";
|
||||
--font-sans: var(--font-desktop-sans), sans-serif;
|
||||
--font-mono:
|
||||
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-weight-normal: 480;
|
||||
--font-weight-medium: 560;
|
||||
--font-weight-semibold: 640;
|
||||
--font-weight-bold: 640;
|
||||
--text-step-1: 12px;
|
||||
--text-step-1--line-height: 16px;
|
||||
--text-step-1--letter-spacing: 0.0025em;
|
||||
--text-step-2: 14px;
|
||||
--text-step-2--line-height: 20px;
|
||||
--text-step-2--letter-spacing: 0em;
|
||||
--text-step-3: 16px;
|
||||
--text-step-3--line-height: 24px;
|
||||
--text-step-3--letter-spacing: 0em;
|
||||
--text-step-4: 18px;
|
||||
--text-step-4--line-height: 26px;
|
||||
--text-step-4--letter-spacing: -0.0025em;
|
||||
--text-step-5: 20px;
|
||||
--text-step-5--line-height: 28px;
|
||||
--text-step-5--letter-spacing: -0.005em;
|
||||
--text-step-6: 24px;
|
||||
--text-step-6--line-height: 30px;
|
||||
--text-step-6--letter-spacing: -0.00625em;
|
||||
--text-step-7: 28px;
|
||||
--text-step-7--line-height: 36px;
|
||||
--text-step-7--letter-spacing: -0.0075em;
|
||||
--text-step-8: 35px;
|
||||
--text-step-8--line-height: 40px;
|
||||
--text-step-8--letter-spacing: -0.01em;
|
||||
--text-step-9: 60px;
|
||||
--text-step-9--line-height: 60px;
|
||||
--text-step-9--letter-spacing: -0.025em;
|
||||
--text-xs: var(--text-step-1);
|
||||
--text-xs--line-height: var(--text-step-1--line-height);
|
||||
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
|
||||
--text-sm: var(--text-step-2);
|
||||
--text-sm--line-height: var(--text-step-2--line-height);
|
||||
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
|
||||
--text-base: var(--text-step-3);
|
||||
--text-base--line-height: var(--text-step-3--line-height);
|
||||
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
|
||||
--text-lg: var(--text-step-4);
|
||||
--text-lg--line-height: var(--text-step-4--line-height);
|
||||
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
|
||||
--text-xl: var(--text-step-5);
|
||||
--text-xl--line-height: var(--text-step-5--line-height);
|
||||
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
|
||||
--text-2xl: var(--text-step-6);
|
||||
--text-2xl--line-height: var(--text-step-6--line-height);
|
||||
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
|
||||
--text-3xl: var(--text-step-7);
|
||||
--text-3xl--line-height: var(--text-step-7--line-height);
|
||||
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
|
||||
--text-4xl: var(--text-step-8);
|
||||
--text-4xl--line-height: var(--text-step-8--line-height);
|
||||
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
|
||||
--text-6xl: var(--text-step-9);
|
||||
--text-6xl--line-height: var(--text-step-9--line-height);
|
||||
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -133,7 +192,7 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply m-0 bg-background text-foreground;
|
||||
@apply m-0 bg-background text-base font-normal text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,3 +265,73 @@
|
||||
::selection {
|
||||
background: oklch(0.75 0.12 165 / 0.25);
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(14%, -10%) rotate(18deg) scale(1.25);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-4%, 6%) rotate(-6deg) scale(1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-12%, -4%) rotate(-16deg) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* Curtain ribbons: sway side-to-side while skewing and stretching, like an
|
||||
aurora curtain rippling. Ribbons are bottom-anchored (transform-origin
|
||||
bottom), so skew/scale fan out from the horizon. */
|
||||
@keyframes aurora-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0) skewX(0deg) scaleY(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
20% {
|
||||
transform: translateX(4%) skewX(8deg) scaleY(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
transform: translateX(-3%) skewX(-10deg) scaleY(0.9);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: translateX(5%) skewX(12deg) scaleY(1.25);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Traveling wave: a 200%-wide striped sheet slides left by half its width,
|
||||
looping seamlessly, while bobbing vertically — bands visibly roll across. */
|
||||
@keyframes aurora-flow {
|
||||
0% {
|
||||
transform: translateX(0) translateY(0) skewX(-6deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-12.5%) translateY(-4%) skewX(4deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-25%) translateY(2%) skewX(-3deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-37.5%) translateY(-5%) skewX(6deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(0) skewX(-6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,6 +826,8 @@ function ChatThreadPane({
|
||||
const displayedIsSwitching = hideDeletedSessionUi
|
||||
? false
|
||||
: isHydratingSession;
|
||||
const isWelcomeState =
|
||||
displayedMessages.length === 0 && !displayedIsSwitching;
|
||||
|
||||
const handleRenameTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
@@ -934,6 +936,7 @@ function ChatThreadPane({
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
showSessionActions={!isWelcomeState}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ type AgentHeaderProps = {
|
||||
canDeleteSession?: boolean;
|
||||
deletingSession?: boolean;
|
||||
onOpenDiff?: () => void;
|
||||
showSessionActions?: boolean;
|
||||
status?: ChatSessionStatus;
|
||||
diff?: {
|
||||
additions: number;
|
||||
@@ -41,6 +42,7 @@ export function AgentHeader({
|
||||
canDeleteSession,
|
||||
deletingSession,
|
||||
onOpenDiff,
|
||||
showSessionActions = true,
|
||||
status,
|
||||
diff,
|
||||
}: AgentHeaderProps) {
|
||||
@@ -164,34 +166,35 @@ export function AgentHeader({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* DIFF */}
|
||||
<Button
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges ? "hover:bg-secondary/80" : "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
{showSessionActions ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges
|
||||
? "hover:bg-secondary/80"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Star {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
// Big blurred gradient blobs that slowly drift/rotate to fake an aurora.
|
||||
// Each entry is [positionClasses, gradient, animationDuration, animationDelay].
|
||||
const BLOBS: Array<[string, string, string, string]> = [
|
||||
[
|
||||
"left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.55 0.2 278 / 0.55), transparent 70%)",
|
||||
"16s",
|
||||
"0s",
|
||||
],
|
||||
[
|
||||
"left-[25%] bottom-[-50%] w-[60%] h-[90%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.65 0.19 200 / 0.4), transparent 70%)",
|
||||
"22s",
|
||||
"-6s",
|
||||
],
|
||||
[
|
||||
"right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.6 0.18 310 / 0.5), transparent 70%)",
|
||||
"19s",
|
||||
"-12s",
|
||||
],
|
||||
[
|
||||
"left-[10%] bottom-[-30%] w-[80%] h-[60%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.75 0.13 340 / 0.35), transparent 70%)",
|
||||
"26s",
|
||||
"-3s",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* A decorative aurora background built entirely from CSS: blurred gradient
|
||||
* blobs drifting on keyframe animations, plus twinkling star dots. No canvas,
|
||||
* no WebGL, no per-frame JS. Absolutely positioned to fill its nearest
|
||||
* positioned parent; pointer events pass through.
|
||||
*
|
||||
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
|
||||
*/
|
||||
export function AuroraBackground({ starCount = 90 }: { starCount?: number }) {
|
||||
// Random star field, generated once per mount.
|
||||
const stars = useMemo<Star[]>(
|
||||
() =>
|
||||
Array.from({ length: starCount }, () => {
|
||||
// Squared skew biases stars toward the bottom, where the glow lives.
|
||||
const r = Math.random();
|
||||
return {
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${100 - (1 - r * r) * 45}%`,
|
||||
size: Math.random() < 0.15 ? 3 : Math.random() < 0.5 ? 2 : 1,
|
||||
delay: `${Math.random() * 4}s`,
|
||||
duration: `${1.5 + Math.random() * 3.5}s`,
|
||||
opacity: 0.3 + Math.random() * 0.6,
|
||||
};
|
||||
}),
|
||||
[starCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{BLOBS.map(([position, gradient, duration, delay], idx) => (
|
||||
<div
|
||||
key={`blob${idx}`}
|
||||
className={`absolute blur-3xl animate-[aurora-drift_linear_infinite] ${position}`}
|
||||
style={{
|
||||
background: gradient,
|
||||
animationDuration: duration,
|
||||
animationDelay: delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{stars.map((s, idx) => (
|
||||
<span
|
||||
key={`star${idx}`}
|
||||
className="absolute rounded-none bg-[#b8f3ee] animate-[aurora-twinkle_ease-in-out_infinite]"
|
||||
style={{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
opacity: s.opacity,
|
||||
animationDelay: s.delay,
|
||||
animationDuration: s.duration,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -376,7 +376,7 @@ function ChatMessagesImpl({
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
<div className="relative mx-auto w-full h-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
{showIdleDetails ? (
|
||||
<WelcomeScreen
|
||||
provider={provider}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Check, FolderOpen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -105,6 +106,7 @@ export function WelcomeScreen({
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden bg-background">
|
||||
<AuroraBackground />
|
||||
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
|
||||
@@ -59,7 +59,7 @@ type InstalledStatusState = "loading" | "ready";
|
||||
const INSTALL_TIMEOUT_MS = 300_000;
|
||||
const CODE_FONT_STYLE: CSSProperties = {
|
||||
fontFamily:
|
||||
'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
'"Azeret Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
};
|
||||
|
||||
const primitivePageDetails = {
|
||||
|
||||
@@ -35,8 +35,9 @@ let resolvedEndpointCache: string | null = null;
|
||||
* or an integration test harness.
|
||||
* 2. Tauri `get_desktop_backend_endpoint` command — used when running inside
|
||||
* the full Tauri app shell.
|
||||
* 3. Fallback to `ws://127.0.0.1:3126/transport` — the sidecar's default port
|
||||
* when running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
|
||||
* 3. `NEXT_PUBLIC_SIDECAR_WS_ENDPOINT` (inlined at build time), then fallback
|
||||
* to `ws://127.0.0.1:3126/transport` — the sidecar's default port when
|
||||
* running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
|
||||
*/
|
||||
export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
if (resolvedEndpointCache) return resolvedEndpointCache;
|
||||
@@ -64,8 +65,10 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
throw new Error("Tauri returned an empty desktop backend endpoint");
|
||||
}
|
||||
|
||||
// 3. Default sidecar port for local dev mode without the Tauri bridge.
|
||||
resolvedEndpointCache = "ws://127.0.0.1:3126/transport";
|
||||
// 3. Env override, then default sidecar port for local dev mode without
|
||||
// the Tauri bridge.
|
||||
const envEndpoint = process.env.NEXT_PUBLIC_SIDECAR_WS_ENDPOINT?.trim();
|
||||
resolvedEndpointCache = envEndpoint || "ws://127.0.0.1:3126/transport";
|
||||
return resolvedEndpointCache;
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,11 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "create" || command === "insert" || command === "str_replace") {
|
||||
if (
|
||||
command === "create" ||
|
||||
command === "insert" ||
|
||||
command === "str_replace"
|
||||
) {
|
||||
const newContent =
|
||||
toStringValue(input.new_text) ??
|
||||
toStringValue(input.file_text) ??
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -11,6 +11,10 @@ const nextConfig = {
|
||||
turbopack: {
|
||||
root: workspaceRoot,
|
||||
},
|
||||
// Dev-only: Next blocks HMR/font/dev-resource requests from origins that
|
||||
// don't match the dev server's own hostname. Both loopback spellings are
|
||||
// legitimate ways to reach a local or port-forwarded dev server.
|
||||
allowedDevOrigins: ["localhost", "127.0.0.1"],
|
||||
reactStrictMode: true,
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-desktop-sans: "Schibsted Grotesk Variable";
|
||||
--font-desktop-mono: "Azeret Mono";
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -75,8 +79,68 @@
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback";
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
||||
--font-sans: var(--font-desktop-sans), sans-serif;
|
||||
--font-mono:
|
||||
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-weight-normal: 480;
|
||||
--font-weight-medium: 560;
|
||||
--font-weight-semibold: 640;
|
||||
--font-weight-bold: 640;
|
||||
--text-step-1: 12px;
|
||||
--text-step-1--line-height: 16px;
|
||||
--text-step-1--letter-spacing: 0.0025em;
|
||||
--text-step-2: 14px;
|
||||
--text-step-2--line-height: 20px;
|
||||
--text-step-2--letter-spacing: 0em;
|
||||
--text-step-3: 16px;
|
||||
--text-step-3--line-height: 24px;
|
||||
--text-step-3--letter-spacing: 0em;
|
||||
--text-step-4: 18px;
|
||||
--text-step-4--line-height: 26px;
|
||||
--text-step-4--letter-spacing: -0.0025em;
|
||||
--text-step-5: 20px;
|
||||
--text-step-5--line-height: 28px;
|
||||
--text-step-5--letter-spacing: -0.005em;
|
||||
--text-step-6: 24px;
|
||||
--text-step-6--line-height: 30px;
|
||||
--text-step-6--letter-spacing: -0.00625em;
|
||||
--text-step-7: 28px;
|
||||
--text-step-7--line-height: 36px;
|
||||
--text-step-7--letter-spacing: -0.0075em;
|
||||
--text-step-8: 35px;
|
||||
--text-step-8--line-height: 40px;
|
||||
--text-step-8--letter-spacing: -0.01em;
|
||||
--text-step-9: 60px;
|
||||
--text-step-9--line-height: 60px;
|
||||
--text-step-9--letter-spacing: -0.025em;
|
||||
--text-xs: var(--text-step-1);
|
||||
--text-xs--line-height: var(--text-step-1--line-height);
|
||||
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
|
||||
--text-sm: var(--text-step-2);
|
||||
--text-sm--line-height: var(--text-step-2--line-height);
|
||||
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
|
||||
--text-base: var(--text-step-3);
|
||||
--text-base--line-height: var(--text-step-3--line-height);
|
||||
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
|
||||
--text-lg: var(--text-step-4);
|
||||
--text-lg--line-height: var(--text-step-4--line-height);
|
||||
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
|
||||
--text-xl: var(--text-step-5);
|
||||
--text-xl--line-height: var(--text-step-5--line-height);
|
||||
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
|
||||
--text-2xl: var(--text-step-6);
|
||||
--text-2xl--line-height: var(--text-step-6--line-height);
|
||||
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
|
||||
--text-3xl: var(--text-step-7);
|
||||
--text-3xl--line-height: var(--text-step-7--line-height);
|
||||
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
|
||||
--text-4xl: var(--text-step-8);
|
||||
--text-4xl--line-height: var(--text-step-8--line-height);
|
||||
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
|
||||
--text-6xl: var(--text-step-9);
|
||||
--text-6xl--line-height: var(--text-step-9--line-height);
|
||||
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -120,6 +184,6 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-base font-normal text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { createRequire } from "node:module"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
@@ -179,39 +178,6 @@ const e2eBuildConfig = {
|
||||
plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin],
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the plugin sandbox bootstrap from the built @cline/core package into
|
||||
* the extension's dist directory. The bootstrap runs in an isolated child
|
||||
* process spawned by SubprocessSandbox and must be a separate file — it cannot
|
||||
* be inlined into the main bundle. resolveBootstrap() (bundled into
|
||||
* extension.js) searches for it at dist/extensions/plugin-sandbox-bootstrap.js.
|
||||
*
|
||||
* The bootstrap has external runtime dependencies (jiti for TypeScript
|
||||
* transpilation, @cline/shared) that it resolves via Node's standard module
|
||||
* resolution from its on-disk location. Both must be direct dependencies of
|
||||
* the extension so they are present in node_modules and resolvable from
|
||||
* dist/extensions/. The CLI build performs the same copy in apps/cli/bun.mts.
|
||||
*/
|
||||
function copyPluginSandboxBootstrap() {
|
||||
if (e2eBuild) return
|
||||
const projectRequire = createRequire(import.meta.url)
|
||||
let corePackageDir
|
||||
try {
|
||||
corePackageDir = path.dirname(projectRequire.resolve("@cline/core/package.json"))
|
||||
} catch {
|
||||
console.warn("[esbuild] @cline/core not found — skipping plugin sandbox bootstrap copy")
|
||||
return
|
||||
}
|
||||
const bootstrapSrc = path.join(corePackageDir, "dist", "extensions", "plugin-sandbox-bootstrap.js")
|
||||
if (!fs.existsSync(bootstrapSrc)) {
|
||||
console.warn(`[esbuild] plugin-sandbox-bootstrap.js not found at ${bootstrapSrc} — build @cline/core first`)
|
||||
return
|
||||
}
|
||||
const bootstrapDest = path.join(__dirname, destDir, "extensions", "plugin-sandbox-bootstrap.js")
|
||||
fs.mkdirSync(path.dirname(bootstrapDest), { recursive: true })
|
||||
fs.copyFileSync(bootstrapSrc, bootstrapDest)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig
|
||||
const extensionCtx = await esbuild.context(config)
|
||||
@@ -221,7 +187,6 @@ async function main() {
|
||||
await extensionCtx.rebuild()
|
||||
await extensionCtx.dispose()
|
||||
}
|
||||
copyPluginSandboxBootstrap()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
"test:e2e:optimal": "bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
|
||||
"install:all": "bun install",
|
||||
"dev:webview": "cd webview-ui && bun run dev",
|
||||
"dev:webview": "node scripts/clean-webview-vite-cache.mjs && cd webview-ui && bun run dev",
|
||||
"build:webview": "bun run protos && cd webview-ui && bun run build",
|
||||
"test:webview": "cd webview-ui && bun run test",
|
||||
"publish:marketplace": "node scripts/publish-marketplace.mjs",
|
||||
@@ -467,7 +467,6 @@
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/sdk": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@grpc/grpc-js": "^1.9.15",
|
||||
@@ -520,7 +519,6 @@
|
||||
"ignore": "^7.0.3",
|
||||
"image-size": "^2.0.2",
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"jiti": "^2.7.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jschardet": "^3.1.4",
|
||||
"json5": "^2.2.3",
|
||||
|
||||
@@ -10,6 +10,23 @@ option java_package = "bot.cline.proto";
|
||||
|
||||
// SlashService provides methods for managing slash commands
|
||||
service SlashService {
|
||||
// Sends button click message
|
||||
rpc reportBug(StringRequest) returns (Empty);
|
||||
rpc condense(StringRequest) returns (Empty);
|
||||
|
||||
// Get available slash commands for autocomplete (used by CLI)
|
||||
rpc getAvailableSlashCommands(EmptyRequest) returns (SlashCommandsResponse);
|
||||
}
|
||||
|
||||
// Slash command definition for autocomplete
|
||||
message SlashCommandInfo {
|
||||
string name = 1; // Command name without slash, e.g., "newtask", "smol"
|
||||
string description = 2; // Human-readable description
|
||||
string section = 3; // "default", "custom", or "cli"
|
||||
bool cli_compatible = 4; // false for VS Code-only commands
|
||||
}
|
||||
|
||||
// Response containing all available slash commands
|
||||
message SlashCommandsResponse {
|
||||
repeated SlashCommandInfo commands = 1;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ enum ClineAsk {
|
||||
USE_MCP_SERVER = 11;
|
||||
NEW_TASK = 12;
|
||||
CONDENSE = 13;
|
||||
REPORT_BUG = 14;
|
||||
SUMMARIZE_TASK = 15;
|
||||
ACT_MODE_RESPOND = 16;
|
||||
USE_SUBAGENTS = 17;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const viteCachePath = path.join(import.meta.dirname, "..", "webview-ui", "node_modules", ".vite")
|
||||
|
||||
await rm(viteCachePath, { recursive: true, force: true })
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
import { setSdkLogger } from "@cline/core"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
@@ -35,6 +36,17 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
|
||||
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
|
||||
|
||||
// Register the SDK early logger so diagnostic events from
|
||||
// ProviderSettingsManager, RuntimeOAuthTokenManager, and Cline auth
|
||||
// flow through Logger.debug → Cline output channel.
|
||||
// These components operate before/outside of ClineCore sessions, so the
|
||||
// session-scoped logger can't reach them.
|
||||
setSdkLogger({
|
||||
debug: (message) => Logger.debug(message),
|
||||
log: (message) => Logger.log(message),
|
||||
error: (message) => Logger.error(message),
|
||||
})
|
||||
|
||||
// Initialize ClineEndpoint configuration (reads bundled and ~/.cline/endpoints.json if present)
|
||||
// This must be done before any other code that calls ClineEnv.config()
|
||||
// Throws ClineConfigurationError if config file exists but is invalid
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { refreshGroqModels } from "../refreshGroqModels"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
axiosGet: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
getModelsCache: vi.fn(),
|
||||
getProviderCollectionSync: vi.fn(),
|
||||
getSecretKey: vi.fn(),
|
||||
setModelsCache: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@cline/llms", () => ({
|
||||
getProviderCollectionSync: mocks.getProviderCollectionSync,
|
||||
}))
|
||||
|
||||
vi.mock("@core/storage/disk", () => ({
|
||||
GlobalFileNames: {
|
||||
groqModels: "groq_models.json",
|
||||
},
|
||||
ensureCacheDirectoryExists: vi.fn(async () => "/tmp/cline-cache"),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getModelsCache: mocks.getModelsCache,
|
||||
setModelsCache: mocks.setModelsCache,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureProviderApiError: mocks.captureProviderApiError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/net", () => ({
|
||||
getAxiosSettings: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@utils/fs", () => ({
|
||||
fileExistsAtPath: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: mocks.axiosGet,
|
||||
isAxiosError: vi.fn(() => false),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: mocks.writeFile,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("refreshGroqModels", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getModelsCache.mockReturnValue(null)
|
||||
mocks.getProviderCollectionSync.mockReturnValue({ models: {} })
|
||||
mocks.getSecretKey.mockReturnValue("gsk_test_key")
|
||||
mocks.axiosGet.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "groq-new-chat-model",
|
||||
object: "model",
|
||||
active: true,
|
||||
max_completion_tokens: 4096,
|
||||
context_window: 8192,
|
||||
owned_by: "Groq",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("defaults cache pricing for live models missing SDK catalog metadata", async () => {
|
||||
const controller = {
|
||||
stateManager: {
|
||||
getSecretKey: mocks.getSecretKey,
|
||||
},
|
||||
task: {
|
||||
ulid: "task-1",
|
||||
},
|
||||
} as unknown as Parameters<typeof refreshGroqModels>[0]
|
||||
|
||||
const models = await refreshGroqModels(controller)
|
||||
|
||||
expect(models["groq-new-chat-model"]).toMatchObject({
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description: "Groq model with 8,192 token context window",
|
||||
})
|
||||
expect(mocks.captureProviderApiError).not.toHaveBeenCalled()
|
||||
expect(mocks.setModelsCache).toHaveBeenCalledWith("groq", expect.objectContaining(models))
|
||||
})
|
||||
})
|
||||
@@ -228,6 +228,34 @@ describe("resolveModelInfo", () => {
|
||||
expect(response.modelInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves a free model id from the cline-pass catalog without coercing to the default", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const store = makeStore({ providerId: parseProviderId("cline-pass") })
|
||||
const catalog = makeCatalog()
|
||||
// The cline-pass catalog carries the endpoint's clinePass bucket plus the
|
||||
// Cline free models (zero-priced, OpenRouter-style ids without the
|
||||
// cline-pass/ prefix). Selecting a free model must not be replaced by the
|
||||
// default pass model.
|
||||
vi.mocked(catalog.peekModels).mockReturnValue(
|
||||
peekResult(
|
||||
"cline-pass",
|
||||
[
|
||||
["cline-pass/glm-5.1", { name: "GLM 5.1", supportsPromptCache: false, contextWindow: 200_000 }],
|
||||
["kwaipilot/kat-coder-pro", { name: "KAT Coder Pro", supportsPromptCache: false, contextWindow: 256_000 }],
|
||||
],
|
||||
"cline-pass/glm-5.1",
|
||||
),
|
||||
)
|
||||
|
||||
const response = await resolveModelInfo(makeController(store, catalog), {
|
||||
providerId: "cline-pass",
|
||||
modelId: "kwaipilot/kat-coder-pro",
|
||||
})
|
||||
|
||||
expect(response.modelId).toBe("kwaipilot/kat-coder-pro")
|
||||
expect(response.source).toBe("sdk-known-models")
|
||||
})
|
||||
|
||||
it("still honors a custom-provider model id that does match the catalog", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const store = makeStore({ providerId: parseProviderId("openai") })
|
||||
|
||||
@@ -139,8 +139,8 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
|
||||
inputPrice: staticModelInfo?.inputPrice || 0,
|
||||
outputPrice: staticModelInfo?.outputPrice || 0,
|
||||
cacheWritesPrice: (staticModelInfo as any)?.cacheWritesPrice || 0,
|
||||
cacheReadsPrice: (staticModelInfo as any).cacheReadsPrice || 0,
|
||||
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
|
||||
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
|
||||
description: generateModelDescription(rawModel, staticModelInfo),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { SlashCommandInfo, SlashCommandsResponse } from "@shared/proto/cline/slash"
|
||||
import { BASE_SLASH_COMMANDS } from "@/shared/slashCommands"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Returns all available slash commands for autocomplete.
|
||||
*/
|
||||
export async function getAvailableSlashCommands(controller: Controller, _request: EmptyRequest): Promise<SlashCommandsResponse> {
|
||||
const commands: SlashCommandInfo[] = []
|
||||
|
||||
// Add built-in commands
|
||||
for (const cmd of [...BASE_SLASH_COMMANDS]) {
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
section: "default",
|
||||
cliCompatible: cmd.cliCompatible,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Get workflow toggles from state
|
||||
const localWorkflowToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles") ?? {}
|
||||
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles") ?? {}
|
||||
const remoteWorkflowToggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles") ?? {}
|
||||
const remoteConfigSettings = controller.stateManager.getRemoteConfigSettings()
|
||||
const remoteWorkflows = remoteConfigSettings?.remoteGlobalWorkflows ?? []
|
||||
|
||||
// Track local workflow names to avoid duplicates from global
|
||||
const localNames = new Set<string>()
|
||||
|
||||
// Add local workflows (enabled only)
|
||||
for (const [path, enabled] of Object.entries(localWorkflowToggles)) {
|
||||
if (enabled) {
|
||||
const fileName = fullPathToFileName(path)
|
||||
localNames.add(fileName)
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: fileName,
|
||||
description: `Custom workflow: ${fileName}`,
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add global workflows (enabled only, skip if local exists with same name)
|
||||
for (const [path, enabled] of Object.entries(globalWorkflowToggles)) {
|
||||
if (enabled) {
|
||||
const fileName = fullPathToFileName(path)
|
||||
if (!localNames.has(fileName)) {
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: fileName,
|
||||
description: `Custom workflow: ${fileName}`,
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remote workflows that are enabled
|
||||
for (const workflow of remoteWorkflows) {
|
||||
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
|
||||
if (enabled) {
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: workflow.name,
|
||||
description: `Remote workflow: ${workflow.name}`,
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return SlashCommandsResponse.create({ commands })
|
||||
}
|
||||
|
||||
function fullPathToFileName(path: string): string {
|
||||
// e.g. replace /path/to/workflow.md with workflow.md
|
||||
return path.replace(/^.*[/\\]/, "")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Empty, StringRequest } from "@shared/proto/cline/common"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Report bug slash command logic
|
||||
*/
|
||||
export async function reportBug(controller: Controller, _request: StringRequest): Promise<Empty> {
|
||||
await controller.task?.handleWebviewAskResponse("yesButtonClicked")
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -27,7 +27,6 @@ export async function getStateToPostToWebview(controller: {
|
||||
backgroundCommandTaskId?: string
|
||||
workspaceManager?: any
|
||||
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
|
||||
getPluginSlashCommands?: () => Promise<{ name: string; description?: string }[]>
|
||||
}): Promise<ExtensionState> {
|
||||
const stateManager = controller.stateManager
|
||||
|
||||
@@ -109,15 +108,6 @@ export async function getStateToPostToWebview(controller: {
|
||||
// Codex OAuth not available
|
||||
}
|
||||
|
||||
// Plugin slash commands are fetched best-effort so autocomplete failures
|
||||
// don't block state posting.
|
||||
let pluginSlashCommands: { name: string; description?: string }[] = []
|
||||
try {
|
||||
pluginSlashCommands = (await controller.getPluginSlashCommands?.()) ?? []
|
||||
} catch {
|
||||
// Plugin command discovery is best-effort.
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
apiConfiguration,
|
||||
@@ -165,7 +155,6 @@ export async function getStateToPostToWebview(controller: {
|
||||
taskHistory: processedTaskHistory,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
pluginSlashCommands,
|
||||
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
|
||||
backgroundCommandTaskId: controller.backgroundCommandTaskId,
|
||||
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
|
||||
|
||||
@@ -117,7 +117,6 @@ export abstract class WebviewProvider {
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" nonce="${nonce}" src="${scriptUrl}"></script>
|
||||
<script src="http://localhost:8097"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -157,7 +156,7 @@ export abstract class WebviewProvider {
|
||||
*/
|
||||
protected async getHMRHtmlContent(): Promise<string> {
|
||||
const localPort = await this.getDevServerPort()
|
||||
const localServerUrl = `localhost:${localPort}`
|
||||
const localServerUrl = `127.0.0.1:${localPort}`
|
||||
|
||||
// Check if local dev server is running.
|
||||
try {
|
||||
@@ -204,7 +203,6 @@ export abstract class WebviewProvider {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { isClineProvider } from "@/shared/utils/cline"
|
||||
import { isClineManagedProvider } from "@/shared/utils/cline"
|
||||
import { resolveWorkspaceRootPath } from "./workspace-root"
|
||||
|
||||
describe("isClineProvider", () => {
|
||||
describe("isClineManagedProvider", () => {
|
||||
it("treats both Cline account providers as Cline providers", () => {
|
||||
expect(isClineProvider("cline")).toBe(true)
|
||||
expect(isClineProvider("cline-pass")).toBe(true)
|
||||
expect(isClineProvider("anthropic")).toBe(false)
|
||||
expect(isClineProvider(undefined)).toBe(false)
|
||||
expect(isClineManagedProvider("cline")).toBe(true)
|
||||
expect(isClineManagedProvider("cline-pass")).toBe(true)
|
||||
expect(isClineManagedProvider("anthropic")).toBe(false)
|
||||
expect(isClineManagedProvider(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import type { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineProvider } from "@/shared/utils/cline"
|
||||
import { isClineManagedProvider } from "@/shared/utils/cline"
|
||||
import { arePathsEqual, getDesktopDir } from "@/utils/path"
|
||||
import { ClineAccountService } from "./account-service"
|
||||
import { AuthService, LogoutReason } from "./auth-service"
|
||||
@@ -71,7 +71,6 @@ import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
|
||||
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
|
||||
import { SdkMessageCoordinator, type SessionEventListener } from "./sdk-message-coordinator"
|
||||
import { SdkModeCoordinator } from "./sdk-mode-coordinator"
|
||||
import { type PluginSlashCommand, SdkPluginCommandCoordinator } from "./sdk-plugin-commands"
|
||||
import { SdkProviderChangeCoordinator } from "./sdk-provider-change-coordinator"
|
||||
import { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
|
||||
import { SdkSessionEventCoordinator } from "./sdk-session-event-coordinator"
|
||||
@@ -167,7 +166,6 @@ export class Controller {
|
||||
private compaction: SdkCompactionCoordinator
|
||||
private sessionEvents: SdkSessionEventCoordinator
|
||||
private sessionHistory: SdkSessionHistoryLoader
|
||||
private pluginCommands: SdkPluginCommandCoordinator
|
||||
private readonly sdkTelemetry: VscodeSdkTelemetryHandle
|
||||
private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate()
|
||||
private readonly providerConfigStore: ProviderConfigStore
|
||||
@@ -337,7 +335,7 @@ export class Controller {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const providerId = this.getSessionProviderId(sessionId) ?? this.getActiveProviderId()
|
||||
const isClineAuthError =
|
||||
isClineProvider(providerId) &&
|
||||
isClineManagedProvider(providerId) &&
|
||||
(errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
|
||||
errorMessage.toLowerCase().includes("missing api key") ||
|
||||
errorMessage.toLowerCase().includes("unauthorized"))
|
||||
@@ -351,7 +349,7 @@ export class Controller {
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
this.emitClineAuthError()
|
||||
} else if (isClineProvider(providerId) && this.isClineBalanceError(errorMessage)) {
|
||||
} else if (isClineManagedProvider(providerId) && this.isClineBalanceError(errorMessage)) {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
@@ -454,7 +452,7 @@ export class Controller {
|
||||
loadInitialMessages: (sessionHost, taskId) => this.sessionHistory.loadInitialMessages(sessionHost, taskId),
|
||||
buildStartSessionInput,
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
isClineProviderActive: () => this.isClineProviderActive(),
|
||||
isClineManagedProviderActive: () => this.isClineManagedProviderActive(),
|
||||
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
@@ -482,7 +480,6 @@ export class Controller {
|
||||
},
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
})
|
||||
this.pluginCommands = new SdkPluginCommandCoordinator()
|
||||
this.taskStart = new SdkTaskStartCoordinator({
|
||||
stateManager: this.stateManager,
|
||||
sessions: this.sessions,
|
||||
@@ -504,7 +501,7 @@ export class Controller {
|
||||
createTempSessionHost: () => VscodeSessionHost.create({ mcpHub: this.mcpHub }),
|
||||
loadInitialMessages: (reader, taskId) => this.sessionHistory.loadInitialMessages(reader, taskId),
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
isClineProviderActive: () => this.isClineProviderActive(),
|
||||
isClineManagedProviderActive: () => this.isClineManagedProviderActive(),
|
||||
emitClineAuthError: (task) => this.emitClineAuthErrorWithTelemetry(task),
|
||||
captureProviderApiError: (event) => this.captureProviderFailure(event),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
@@ -514,10 +511,7 @@ export class Controller {
|
||||
sessions: this.sessions,
|
||||
messages: this.messages,
|
||||
sessionConfigBuilder: this.sessionConfigBuilder,
|
||||
getTask: () => this.task,
|
||||
getWorkspaceRoot: () => this.getWorkspaceRoot(),
|
||||
buildStartSessionInput,
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
})
|
||||
this.sessionEvents = new SdkSessionEventCoordinator({
|
||||
@@ -578,16 +572,6 @@ export class Controller {
|
||||
this.providerCatalog.invalidateProviderListings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return plugin-registered slash commands for autocomplete. Surfaced to
|
||||
* the webview as `pluginSlashCommands` in ExtensionState (see
|
||||
* getStateToPostToWebview) so the chat input's slash-command menu can
|
||||
* show them.
|
||||
*/
|
||||
getPluginSlashCommands(): Promise<PluginSlashCommand[]> {
|
||||
return this.pluginCommands.getSlashCommands()
|
||||
}
|
||||
|
||||
private handleProviderConfigChange(event: ProviderConfigChange): void {
|
||||
this.scheduleProviderConfigStatePost()
|
||||
|
||||
@@ -690,7 +674,6 @@ export class Controller {
|
||||
// are disposed below — see StatePostDebouncer.dispose().
|
||||
await this.statePostDebouncer.dispose()
|
||||
await this.invalidateUserInstructionService()
|
||||
await this.pluginCommands.dispose()
|
||||
this.messages.cancelPendingSave()
|
||||
// Clear MCP tool list change callback before disposing McpHub
|
||||
this.mcpHub?.clearToolListChangeCallback()
|
||||
@@ -751,48 +734,14 @@ export class Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a leading slash command. First checks plugin-registered commands
|
||||
* (e.g. `/goal`), then falls back to workflow/skill expansion via the
|
||||
* user-instruction service. For plugin commands:
|
||||
* - If the handler returns `submitPrompt`, that becomes the prompt text.
|
||||
* - If the handler returns `reply`, it is emitted as a say message.
|
||||
* - If only `reply` is returned (no `submitPrompt`), returns empty string
|
||||
* so the agent turn is suppressed (the reply was already shown).
|
||||
* Returns the input unchanged if it is not a known command.
|
||||
* Expand a leading `/workflow` or `/skill` slash command into its instruction
|
||||
* body. Mirrors the CLI's `buildUserInputMessage`. Returns the input unchanged
|
||||
* if it is not a known command or expansion fails.
|
||||
*/
|
||||
private async resolveSlashCommands(text: string): Promise<string> {
|
||||
if (this.isDisposed) {
|
||||
return text
|
||||
}
|
||||
|
||||
// Check plugin commands first — they take precedence over
|
||||
// workflow/skill expansion so plugin names cannot be shadowed.
|
||||
try {
|
||||
const result = await this.pluginCommands.resolveCommand(text)
|
||||
if (result) {
|
||||
if (result.reply) {
|
||||
this.messages.emitSessionEvents(
|
||||
[
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: result.reply,
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
type: "status",
|
||||
payload: { sessionId: this.sessions.getActiveSession()?.sessionId ?? "", status: "running" },
|
||||
},
|
||||
)
|
||||
}
|
||||
return result.submitPrompt ?? ""
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn("[SdkController] Plugin command resolution failed, falling through:", error)
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceRoot = await this.getWorkspaceRoot()
|
||||
const service = await this.ensureUserInstructionService(workspaceRoot)
|
||||
@@ -945,8 +894,8 @@ export class Controller {
|
||||
/**
|
||||
* Check if the active API provider uses Cline account auth for the current mode.
|
||||
*/
|
||||
private isClineProviderActive(): boolean {
|
||||
return isClineProvider(this.getActiveProviderId())
|
||||
private isClineManagedProviderActive(): boolean {
|
||||
return isClineManagedProvider(this.getActiveProviderId())
|
||||
}
|
||||
|
||||
private captureProviderFailure(event: ProviderFailureTelemetry): void {
|
||||
@@ -1187,8 +1136,8 @@ export class Controller {
|
||||
* Manually compact (condense) the active task's conversation. Triggered by
|
||||
* the compact button and the `/compact` (alias `/smol`) slash command.
|
||||
* Mirrors the CLI's `/compact` local command: runs an SDK manual compaction
|
||||
* and restarts the session with the compacted transcript so the model's
|
||||
* working context is actually reduced.
|
||||
* and persists the compaction sidecar so the model's working context is
|
||||
* reduced on the next turn and later resumes.
|
||||
*/
|
||||
async compactTask(): Promise<void> {
|
||||
await this.compaction.compactTask()
|
||||
@@ -1851,7 +1800,6 @@ export class Controller {
|
||||
mcpHub: this.mcpHub,
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
getPluginSlashCommands: () => this.pluginCommands.getSlashCommands(),
|
||||
})
|
||||
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
|
||||
.map(sessionHistoryRecordToHistoryItem)
|
||||
|
||||
@@ -103,7 +103,9 @@ vi.mock("axios", () => ({
|
||||
const mockLoginClineOAuth = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock @cline/core OAuth functions
|
||||
vi.mock("@cline/core", () => ({
|
||||
vi.mock("@cline/core", async () => ({
|
||||
sdkDebug: () => {},
|
||||
hashSecret: () => "hashed",
|
||||
createOAuthClientCallbacks: (opts: {
|
||||
onOutput?: (message: string) => void
|
||||
onPrompt: () => void
|
||||
|
||||
@@ -12,9 +12,11 @@ import type { OAuthCredentials } from "@cline/core"
|
||||
import {
|
||||
createOAuthClientCallbacks,
|
||||
getValidClineCredentials,
|
||||
hashSecret,
|
||||
loginClineOAuth,
|
||||
loginOcaOAuth,
|
||||
loginOpenAICodex,
|
||||
sdkDebug,
|
||||
} from "@cline/core"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { AuthState, UserInfo } from "@shared/proto/cline/account"
|
||||
@@ -99,7 +101,10 @@ function readClineCredentials(): {
|
||||
try {
|
||||
const manager = getProviderSettingsManager()
|
||||
const settings = manager.getProviderSettings("cline")
|
||||
if (!settings?.auth?.accessToken) return null
|
||||
if (!settings?.auth?.accessToken) {
|
||||
sdkDebug("[SdkAuthService] readClineCredentials: no auth.accessToken found")
|
||||
return null
|
||||
}
|
||||
|
||||
// Strip workos: prefix if present (providers.json stores it with prefix)
|
||||
let accessToken = settings.auth.accessToken
|
||||
@@ -107,12 +112,16 @@ function readClineCredentials(): {
|
||||
accessToken = accessToken.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
accessToken,
|
||||
refreshToken: settings.auth.refreshToken,
|
||||
expiresAt: (settings.auth as { expiresAt?: number }).expiresAt,
|
||||
accountId: settings.auth.accountId,
|
||||
}
|
||||
sdkDebug(
|
||||
`[SdkAuthService] readClineCredentials: found credentials (accessHash=${hashSecret(result.accessToken)}, refreshHash=${hashSecret(result.refreshToken)}, expiresAt=${result.expiresAt})`,
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
Logger.error("[SdkAuthService] Failed to read credentials from providers.json:", error)
|
||||
return null
|
||||
@@ -150,6 +159,9 @@ function writeClineCredentials(credentials: {
|
||||
},
|
||||
{ tokenSource: "oauth", setLastUsed: true },
|
||||
)
|
||||
sdkDebug(
|
||||
`[SdkAuthService] writeClineCredentials: wrote (accessHash=${hashSecret(credentials.accessToken)}, refreshHash=${hashSecret(credentials.refreshToken)}, expiresAt=${credentials.expiresAt})`,
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("[SdkAuthService] Failed to write credentials to providers.json:", error)
|
||||
}
|
||||
@@ -163,6 +175,7 @@ function clearClineCredentials(): void {
|
||||
const manager = getProviderSettingsManager()
|
||||
const existing = manager.getProviderSettings("cline")
|
||||
if (existing) {
|
||||
sdkDebug("[SdkAuthService] clearClineCredentials: clearing auth from providers.json")
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...existing,
|
||||
@@ -251,6 +264,9 @@ export class AuthService {
|
||||
const bearerToken = accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? accessToken
|
||||
: `${WORKOS_TOKEN_PREFIX}${accessToken}`
|
||||
sdkDebug(
|
||||
`[SdkAuthService] fetchUserInfoFromApi: GET ${apiBaseUrl}/api/v1/users/me (tokenHash=${hashSecret(accessToken)})`,
|
||||
)
|
||||
const response = await axios.get(`${apiBaseUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
@@ -259,6 +275,7 @@ export class AuthService {
|
||||
},
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
sdkDebug(`[SdkAuthService] fetchUserInfoFromApi: response status=${response.status}`)
|
||||
return response.data?.data ?? null
|
||||
} catch (error) {
|
||||
Logger.error("[SdkAuthService] Failed to fetch user info from API:", error)
|
||||
@@ -335,9 +352,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (!this._clineAuthInfo?.refreshToken) {
|
||||
sdkDebug("[SdkAuthService] refreshAccessToken: no refresh token available")
|
||||
return false
|
||||
}
|
||||
|
||||
sdkDebug(`[SdkAuthService] refreshAccessToken: starting (currentTokenHash=${hashSecret(this._clineAuthInfo.idToken)})`)
|
||||
this._refreshPromise = (async () => {
|
||||
try {
|
||||
const currentInfo = this._clineAuthInfo
|
||||
@@ -346,6 +365,7 @@ export class AuthService {
|
||||
}
|
||||
const newCredentials = await this.resolveValidClineCredentials(currentInfo, { forceRefresh: true })
|
||||
if (!newCredentials) {
|
||||
sdkDebug("[SdkAuthService] refreshAccessToken: refresh returned null — clearing credentials")
|
||||
this._clineAuthInfo = null
|
||||
this._authenticated = false
|
||||
clearClineCredentials()
|
||||
@@ -375,6 +395,9 @@ export class AuthService {
|
||||
this._authenticated = true
|
||||
|
||||
if (credentialsChanged) {
|
||||
sdkDebug(
|
||||
`[SdkAuthService] refreshAccessToken: credentials changed (newTokenHash=${hashSecret(newCredentials.access)})`,
|
||||
)
|
||||
writeClineCredentials({
|
||||
accessToken: newCredentials.access,
|
||||
refreshToken: newCredentials.refresh,
|
||||
@@ -387,6 +410,8 @@ export class AuthService {
|
||||
Logger.error("[SdkAuthService] Error sending auth status update after refresh:", err)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
sdkDebug("[SdkAuthService] refreshAccessToken: credentials unchanged after refresh")
|
||||
}
|
||||
|
||||
return this._clineAuthInfo.idToken
|
||||
|
||||
@@ -622,16 +622,16 @@ describe("buildSessionConfig", () => {
|
||||
it("uses ClinePass model storage and omits empty nested apiKey so SDK OAuth can fill it", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "cline-pass",
|
||||
actModeClinePassModelId: "cline-pass/glm-5.1",
|
||||
actModeClinePassModelId: "cline-pass/glm-5.2",
|
||||
} as any)
|
||||
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerId).toBe("cline-pass")
|
||||
expect(config.modelId).toBe("cline-pass/glm-5.1")
|
||||
expect(config.modelId).toBe("cline-pass/glm-5.2")
|
||||
expect(config.apiKey).toBe("")
|
||||
expect(config.providerConfig).toMatchObject({ providerId: "cline-pass", modelId: "cline-pass/glm-5.1" })
|
||||
expect(config.providerConfig).toMatchObject({ providerId: "cline-pass", modelId: "cline-pass/glm-5.2" })
|
||||
expect(config.providerConfig).not.toHaveProperty("apiKey")
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import { buildClineSystemPrompt } from "@cline/shared"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { ClineClient } from "@shared/cline"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, type LanguageDisplay } from "@shared/Languages"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
@@ -27,6 +28,7 @@ import type { Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { getFeatureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
@@ -117,6 +119,31 @@ function createSdkLogger() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host identity for the session's client context, resolved through HostProvider
|
||||
* rather than the `vscode` module directly: this file is also bundled into the
|
||||
* standalone cline-core (JetBrains), where `vscode` is a Proxy-stub module and
|
||||
* direct API reads would yield non-string values. The hostbridge returns the
|
||||
* per-host values (e.g. "Cline for JetBrains" + IDE version on JetBrains).
|
||||
*/
|
||||
async function resolveHostIdentity() {
|
||||
try {
|
||||
return await HostProvider.env.getHostVersion({})
|
||||
} catch (error) {
|
||||
Logger.debug("Failed to resolve host version for client identity", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveIsMultiRootWorkspace(): Promise<boolean> {
|
||||
try {
|
||||
const { paths } = await HostProvider.workspace.getWorkspacePaths({})
|
||||
return paths.length > 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceName(workspacePath: string): string {
|
||||
const trimmed = workspacePath.trim()
|
||||
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/, "")
|
||||
@@ -664,6 +691,8 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
// own provider id spelling (e.g. "openai-compatible" rather than the
|
||||
// extension's "openai"). Convert before handing the id to core.
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const hostIdentity = await resolveHostIdentity()
|
||||
const isMultiRoot = await resolveIsMultiRootWorkspace()
|
||||
|
||||
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
|
||||
// gateway; without it the agent loop uses bare global fetch and corporate
|
||||
@@ -714,8 +743,11 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
extensionContext: {
|
||||
user: distinctId ? { distinctId } : undefined,
|
||||
client: {
|
||||
name: "cline-vscode",
|
||||
version: ExtensionRegistryInfo.version,
|
||||
name: hostIdentity?.clineType || ClineClient.VSCode,
|
||||
version: hostIdentity?.clineVersion || ExtensionRegistryInfo.version,
|
||||
platform: hostIdentity?.platform || undefined,
|
||||
platformVersion: hostIdentity?.version || undefined,
|
||||
isMultiRoot,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
|
||||
@@ -783,6 +783,101 @@ describe("translateSessionEvent — agent_event content_end", () => {
|
||||
expect(second.path).toBe("/src/package.json")
|
||||
})
|
||||
|
||||
it("content_end for read_files carries the requested line range into the readFile payload", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
|
||||
translateSessionEvent(
|
||||
{
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "s1",
|
||||
event: {
|
||||
type: "content_start",
|
||||
contentType: "tool",
|
||||
toolName: "read_files",
|
||||
toolCallId: "c1",
|
||||
input: { files: [{ path: "/src/big-file.ts", start_line: 100, end_line: 200 }] },
|
||||
} as AgentEvent,
|
||||
},
|
||||
},
|
||||
state,
|
||||
)
|
||||
|
||||
const endResult = translateSessionEvent(
|
||||
{
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "s1",
|
||||
event: {
|
||||
type: "content_end",
|
||||
contentType: "tool",
|
||||
toolName: "read_files",
|
||||
toolCallId: "c1",
|
||||
} as AgentEvent,
|
||||
},
|
||||
},
|
||||
state,
|
||||
)
|
||||
|
||||
const endTool = JSON.parse(endResult.messages[0].text!)
|
||||
expect(endTool.tool).toBe("readFile")
|
||||
expect(endTool.path).toBe("/src/big-file.ts")
|
||||
expect(endTool.readLineStart).toBe(100)
|
||||
expect(endTool.readLineEnd).toBe(200)
|
||||
})
|
||||
|
||||
it("content_end for read_files treats a start_line-only read as open-ended and defaults a missing start_line to 1", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
|
||||
translateSessionEvent(
|
||||
{
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "s1",
|
||||
event: {
|
||||
type: "content_start",
|
||||
contentType: "tool",
|
||||
toolName: "read_files",
|
||||
toolCallId: "c1",
|
||||
input: {
|
||||
files: [
|
||||
{ path: "/src/paged.ts", start_line: 500, end_line: null },
|
||||
{ path: "/src/head.ts", end_line: 50 },
|
||||
{ path: "/src/whole.ts" },
|
||||
],
|
||||
},
|
||||
} as AgentEvent,
|
||||
},
|
||||
},
|
||||
state,
|
||||
)
|
||||
|
||||
const endResult = translateSessionEvent(
|
||||
{
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "s1",
|
||||
event: {
|
||||
type: "content_end",
|
||||
contentType: "tool",
|
||||
toolName: "read_files",
|
||||
toolCallId: "c1",
|
||||
} as AgentEvent,
|
||||
},
|
||||
},
|
||||
state,
|
||||
)
|
||||
|
||||
expect(endResult.messages).toHaveLength(3)
|
||||
const [paged, head, whole] = endResult.messages.map((m) => JSON.parse(m.text!))
|
||||
expect(paged.readLineStart).toBe(500)
|
||||
expect(paged.readLineEnd).toBeUndefined()
|
||||
expect(head.readLineStart).toBe(1)
|
||||
expect(head.readLineEnd).toBe(50)
|
||||
expect(whole.readLineStart).toBeUndefined()
|
||||
expect(whole.readLineEnd).toBeUndefined()
|
||||
})
|
||||
|
||||
it("content_end without prior content_start still works (graceful fallback)", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
|
||||
@@ -1149,6 +1244,25 @@ describe("translateSessionEvent — agent_event error", () => {
|
||||
expect(parsed.providerId).toBe("cline")
|
||||
})
|
||||
|
||||
it("preserves ClinePass period limit errors for specialized webview rendering", () => {
|
||||
const state = new MessageTranslatorState(undefined, () => "cline-pass")
|
||||
const message = "You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later."
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: {
|
||||
type: "error",
|
||||
error: { message },
|
||||
} as AgentEvent,
|
||||
},
|
||||
}
|
||||
|
||||
const result = translateSessionEvent(event, state)
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages[1].text).toBe(message)
|
||||
})
|
||||
|
||||
it("rewrites Anthropic bare 'model: <id>' 404 into an actionable message", () => {
|
||||
const state = new MessageTranslatorState(undefined, () => "anthropic")
|
||||
const event: CoreSessionEvent = {
|
||||
|
||||
@@ -452,10 +452,11 @@ function sdkToolToClineSayTool(toolName: string, input?: unknown): ClineSayTool
|
||||
switch (toolName) {
|
||||
case "read_files":
|
||||
case "read_file": {
|
||||
const filePath = extractFirstFilePath(parsedInput)
|
||||
const fileRead = extractFileReads(parsedInput)[0]
|
||||
return {
|
||||
tool: "readFile",
|
||||
path: filePath,
|
||||
path: fileRead?.path ?? "",
|
||||
...readLineRangeFields(fileRead),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,32 +666,53 @@ function getCompletionResultText(input: unknown): string {
|
||||
return getStringField(parsed, "summary") ?? getStringField(parsed, "result") ?? ""
|
||||
}
|
||||
|
||||
/** Extract file paths from a read_files/read_file input */
|
||||
function extractFilePaths(input: Record<string, unknown> | undefined): string[] {
|
||||
/** A single file read request parsed from a read_files/read_file input */
|
||||
interface FileReadRequest {
|
||||
path: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}
|
||||
|
||||
/** Extract file read requests (path + optional one-based inclusive line range) from a read_files/read_file input */
|
||||
function extractFileReads(input: Record<string, unknown> | undefined): FileReadRequest[] {
|
||||
if (!input) return []
|
||||
const files = input.files
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
const paths = files
|
||||
.map((f) => {
|
||||
if (typeof f === "string") return f
|
||||
const reads = files
|
||||
.map((f): FileReadRequest => {
|
||||
if (typeof f === "string") return { path: f }
|
||||
if (typeof f === "object" && f !== null) {
|
||||
return ((f as Record<string, unknown>).path as string) ?? ""
|
||||
const entry = f as Record<string, unknown>
|
||||
return {
|
||||
path: (entry.path as string) ?? "",
|
||||
startLine: getNumberField(entry, "start_line"),
|
||||
endLine: getNumberField(entry, "end_line"),
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return { path: "" }
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (paths.length > 0) {
|
||||
return paths
|
||||
.filter((read) => read.path)
|
||||
if (reads.length > 0) {
|
||||
return reads
|
||||
}
|
||||
}
|
||||
const singlePath =
|
||||
(input.path as string) ?? (input.file_path as string) ?? (input.filePath as string) ?? (input.filename as string) ?? ""
|
||||
return singlePath ? [singlePath] : []
|
||||
return singlePath
|
||||
? [{ path: singlePath, startLine: getNumberField(input, "start_line"), endLine: getNumberField(input, "end_line") }]
|
||||
: []
|
||||
}
|
||||
|
||||
/** Extract the first file path from a read_files input */
|
||||
function extractFirstFilePath(input: Record<string, unknown> | undefined): string {
|
||||
return extractFilePaths(input)[0] ?? ""
|
||||
/**
|
||||
* Map a read request's line range onto ClineSayTool fields. An omitted start_line with an
|
||||
* explicit end_line means the read began at line 1; an omitted end_line stays undefined
|
||||
* (open-ended read — the UI renders it as "start+").
|
||||
*/
|
||||
function readLineRangeFields(read: FileReadRequest | undefined): Pick<ClineSayTool, "readLineStart" | "readLineEnd"> {
|
||||
if (!read || (read.startLine == null && read.endLine == null)) {
|
||||
return {}
|
||||
}
|
||||
return { readLineStart: read.startLine ?? 1, readLineEnd: read.endLine }
|
||||
}
|
||||
|
||||
/** Get a string field from a parsed input object */
|
||||
@@ -701,6 +723,14 @@ function getStringField(input: Record<string, unknown> | undefined, field: strin
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Get a finite number field from a parsed input object (null/non-number → undefined) */
|
||||
function getNumberField(input: Record<string, unknown> | undefined, field: string): number | undefined {
|
||||
if (!input) return undefined
|
||||
const value = input[field]
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Get an array field from a parsed input object */
|
||||
function getArrayField(input: Record<string, unknown> | undefined, field: string): string[] | undefined {
|
||||
if (!input) return undefined
|
||||
@@ -1295,16 +1325,17 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
// list reflect what was actually read.
|
||||
if (toolName === "read_files" || toolName === "read_file") {
|
||||
const parsedInput = parseToolInput(storedInput)
|
||||
const filePaths = extractFilePaths(parsedInput)
|
||||
if (filePaths.length > 1) {
|
||||
filePaths.forEach((filePath, index) => {
|
||||
const fileReads = extractFileReads(parsedInput)
|
||||
if (fileReads.length > 1) {
|
||||
fileReads.forEach((fileRead, index) => {
|
||||
messages.push({
|
||||
ts: index === 0 ? ts : state.nextTs(),
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "readFile",
|
||||
path: filePath,
|
||||
path: fileRead.path,
|
||||
...readLineRangeFields(fileRead),
|
||||
} satisfies ClineSayTool),
|
||||
partial: false,
|
||||
})
|
||||
|
||||
@@ -43,14 +43,14 @@ describe("buildSdkProviderConfig", () => {
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "cline-pass",
|
||||
actModeClinePassModelId: "cline-pass/glm-5.1",
|
||||
actModeClinePassModelId: "cline-pass/glm-5.2",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
modelId: "cline-pass/glm-5.2",
|
||||
apiKey: "workos:shared-cline-token",
|
||||
})
|
||||
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("cline")
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { createContextCompactionPrepareTurn } from "@cline/core"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { SdkCompactionCoordinator, type SdkCompactionCoordinatorOptions } from "./sdk-compaction-coordinator"
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
createContextCompactionPrepareTurn: vi.fn(),
|
||||
createSessionCompactionState: vi.fn((input: { compactedMessages: unknown[] }) => ({
|
||||
version: 1,
|
||||
messages: input.compactedMessages,
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockCreateContextCompactionPrepareTurn = createContextCompactionPrepareTurn as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: {
|
||||
debug: vi.fn(),
|
||||
@@ -12,11 +22,6 @@ vi.mock("@/shared/services/Logger", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const compactSessionMessages = vi.fn()
|
||||
vi.mock("./sdk-compaction", () => ({
|
||||
compactSessionMessages: (...args: unknown[]) => compactSessionMessages(...args),
|
||||
}))
|
||||
|
||||
describe("SdkCompactionCoordinator", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -28,7 +33,7 @@ describe("SdkCompactionCoordinator", () => {
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(compactSessionMessages).not.toHaveBeenCalled()
|
||||
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: "There is no active task to compact." })],
|
||||
expect.anything(),
|
||||
@@ -41,7 +46,7 @@ describe("SdkCompactionCoordinator", () => {
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(compactSessionMessages).not.toHaveBeenCalled()
|
||||
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: expect.stringContaining("Cannot compact while a response") })],
|
||||
@@ -56,7 +61,7 @@ describe("SdkCompactionCoordinator", () => {
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(compactSessionMessages).not.toHaveBeenCalled()
|
||||
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: "No messages to compact." })],
|
||||
@@ -64,17 +69,29 @@ describe("SdkCompactionCoordinator", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("reports when the strategy declines to compact", async () => {
|
||||
it("reports unsupported runtime without running compaction", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
;(activeSession.sdkHost as Partial<typeof activeSession.sdkHost>).updateSessionCompactionState = undefined
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
compactSessionMessages.mockResolvedValueOnce({
|
||||
compacted: false,
|
||||
messages: [{ role: "user", content: "a" }],
|
||||
})
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(compactSessionMessages).toHaveBeenCalledOnce()
|
||||
expect(activeSession.sdkHost.readMessages).not.toHaveBeenCalled()
|
||||
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: expect.stringContaining("not supported") })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("reports when the strategy declines to compact", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(mockCreateContextCompactionPrepareTurn).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: "No compaction needed." })],
|
||||
@@ -82,53 +99,74 @@ describe("SdkCompactionCoordinator", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("compacts and restarts the session, preserving the session id", async () => {
|
||||
it("compacts and persists the sidecar without rebuilding the session", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
activeSession.sdkHost.readMessages.mockResolvedValueOnce([
|
||||
{ role: "user", content: "1" },
|
||||
{ role: "assistant", content: "2" },
|
||||
{ role: "user", content: "3" },
|
||||
])
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options } = makeCoordinator({ activeSession, task })
|
||||
compactSessionMessages.mockResolvedValueOnce({
|
||||
compacted: true,
|
||||
messages: [{ role: "user", content: "summary" }],
|
||||
})
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(
|
||||
vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] }),
|
||||
)
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "old-session" }), {
|
||||
cwd: "/workspace",
|
||||
mode: "act",
|
||||
expect(activeSession.sdkHost.updateSessionCompactionState).toHaveBeenCalledWith("old-session", {
|
||||
version: 1,
|
||||
messages: [{ role: "user", content: "summary" }],
|
||||
})
|
||||
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith({
|
||||
startInput: expect.objectContaining({
|
||||
config: expect.objectContaining({ sessionId: "old-session" }),
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
}),
|
||||
initialMessages: [{ role: "user", content: "summary" }],
|
||||
disposeReason: "compactTask",
|
||||
})
|
||||
expect(task.taskId).toBe("new-session")
|
||||
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: "Compacted 3 messages to 1." })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("does not append compaction status to a different active session", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
options.sessions.getActiveSession
|
||||
.mockReturnValueOnce(activeSession)
|
||||
.mockReturnValueOnce(makeActiveSession({ sessionId: "other-session" }))
|
||||
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(
|
||||
vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] }),
|
||||
)
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(activeSession.sdkHost.updateSessionCompactionState).toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not restart or report success when sidecar persistence fails", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
activeSession.sdkHost.updateSessionCompactionState.mockResolvedValueOnce({ updated: false })
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(
|
||||
vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] }),
|
||||
)
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: "Couldn't compact the conversation. Please try again." })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("reports a failure when compaction throws", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
compactSessionMessages.mockRejectedValueOnce(new Error("boom"))
|
||||
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(vi.fn().mockRejectedValue(new Error("boom")))
|
||||
|
||||
await coordinator.compactTask()
|
||||
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "info", text: "Compaction failed: boom" })],
|
||||
[expect.objectContaining({ say: "info", text: "Couldn't compact the conversation. Please try again." })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
@@ -136,7 +174,6 @@ describe("SdkCompactionCoordinator", () => {
|
||||
|
||||
interface MakeCoordinatorInput {
|
||||
activeSession: ReturnType<typeof makeActiveSession> | undefined
|
||||
task: ReturnType<typeof makeTask>
|
||||
}
|
||||
|
||||
function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
@@ -168,14 +205,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
sessionConfigBuilder: {
|
||||
build: vi.fn().mockResolvedValue(config),
|
||||
},
|
||||
getTask: vi.fn(() => input.task),
|
||||
getWorkspaceRoot: vi.fn().mockResolvedValue("/workspace"),
|
||||
buildStartSessionInput: vi.fn((startConfig) => ({
|
||||
config: startConfig,
|
||||
prompt: undefined,
|
||||
interactive: true,
|
||||
})),
|
||||
resetMessageTranslator: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as SdkCompactionCoordinatorOptions & {
|
||||
sessions: SdkCompactionCoordinatorOptions["sessions"] & {
|
||||
@@ -188,8 +218,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
sessionConfigBuilder: SdkCompactionCoordinatorOptions["sessionConfigBuilder"] & {
|
||||
build: ReturnType<typeof vi.fn>
|
||||
}
|
||||
buildStartSessionInput: ReturnType<typeof vi.fn>
|
||||
resetMessageTranslator: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -199,11 +227,12 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeActiveSession(input: { isRunning?: boolean } = {}) {
|
||||
function makeActiveSession(input: { isRunning?: boolean; sessionId?: string } = {}) {
|
||||
return {
|
||||
sessionId: "old-session",
|
||||
sessionId: input.sessionId ?? "old-session",
|
||||
sdkHost: {
|
||||
readMessages: vi.fn().mockResolvedValue([{ role: "user", content: "1" }]),
|
||||
updateSessionCompactionState: vi.fn().mockResolvedValue({ updated: true }),
|
||||
send: vi.fn(),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -214,15 +243,3 @@ function makeActiveSession(input: { isRunning?: boolean } = {}) {
|
||||
isRunning: input.isRunning ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTask(taskId: string, messages: Array<Partial<ClineMessage>> = []) {
|
||||
return {
|
||||
taskId,
|
||||
messageStateHandler: {
|
||||
getClineMessages: vi.fn(() => messages as ClineMessage[]),
|
||||
},
|
||||
} as unknown as {
|
||||
taskId: string
|
||||
messageStateHandler: { getClineMessages: () => ClineMessage[] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
//
|
||||
// 1. Read the active session's transcript.
|
||||
// 2. Run a manual SDK compaction over it (sdk-compaction.ts).
|
||||
// 3. Restart the session with the compacted messages as initialMessages, so
|
||||
// the model's working context is actually reduced (reusing the mode-rebuild
|
||||
// replaceActiveSession path, which lazily persists on the next turn).
|
||||
// 3. Persist the SDK compaction sidecar so the next turn and resumes keep
|
||||
// using the compacted working context.
|
||||
//
|
||||
// Before this, the VSCode button sent the literal text "/compact" to the model,
|
||||
// which the SDK does not treat as a runtime command, so the model improvised a
|
||||
@@ -24,22 +23,16 @@ import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
|
||||
import type { SdkSessionLifecycle } from "./sdk-session-lifecycle"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
import type { TaskProxy } from "./task-proxy"
|
||||
import type { VscodeSessionHost } from "./vscode-session-host"
|
||||
|
||||
type StartInput = Parameters<VscodeSessionHost["start"]>[0]
|
||||
type InitialMessages = StartInput["initialMessages"]
|
||||
type SessionConfig = Awaited<ReturnType<SdkSessionConfigBuilder["build"]>>
|
||||
const COMPACTION_FAILURE_MESSAGE = "Couldn't compact the conversation. Please try again."
|
||||
const COMPACTION_UNSUPPORTED_MESSAGE = "Compaction is not supported by this runtime yet. Please update Cline and try again."
|
||||
|
||||
export interface SdkCompactionCoordinatorOptions {
|
||||
stateManager: StateManager
|
||||
sessions: SdkSessionLifecycle
|
||||
messages: SdkMessageCoordinator
|
||||
sessionConfigBuilder: SdkSessionConfigBuilder
|
||||
getTask: () => TaskProxy | undefined
|
||||
getWorkspaceRoot: () => Promise<string>
|
||||
buildStartSessionInput: (config: SessionConfig, input: { cwd: string; mode: Mode }) => StartInput
|
||||
resetMessageTranslator: () => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -70,7 +63,10 @@ export class SdkCompactionCoordinator {
|
||||
// A turn is still running; compacting mid-turn would race the live agent
|
||||
// loop's own message persistence. Ask the user to wait until it finishes.
|
||||
if (activeSession.isRunning) {
|
||||
this.emitInfo("Cannot compact while a response is in progress. Try again once the current turn finishes.")
|
||||
this.emitInfo(
|
||||
"Cannot compact while a response is in progress. Try again once the current turn finishes.",
|
||||
activeSession.sessionId,
|
||||
)
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
}
|
||||
@@ -80,7 +76,7 @@ export class SdkCompactionCoordinator {
|
||||
await this.runCompaction(activeSession.sdkHost, activeSession.sessionId)
|
||||
} catch (error) {
|
||||
Logger.error("[SdkController] compactTask failed:", error)
|
||||
this.emitInfo(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
this.emitInfo(COMPACTION_FAILURE_MESSAGE, activeSession.sessionId)
|
||||
await this.options.postStateToWebview()
|
||||
} finally {
|
||||
this.compactInFlight = false
|
||||
@@ -88,10 +84,15 @@ export class SdkCompactionCoordinator {
|
||||
}
|
||||
|
||||
private async runCompaction(sdkHost: SdkSessionHost, sessionId: string): Promise<void> {
|
||||
if (!sdkHost.updateSessionCompactionState) {
|
||||
this.emitInfo(COMPACTION_UNSUPPORTED_MESSAGE, sessionId)
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
}
|
||||
const messages = (await sdkHost.readMessages(sessionId)) as SdkMessage[]
|
||||
const messagesBefore = messages.length
|
||||
if (messagesBefore === 0) {
|
||||
this.emitInfo("No messages to compact.")
|
||||
this.emitInfo("No messages to compact.", sessionId)
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
}
|
||||
@@ -115,44 +116,23 @@ export class SdkCompactionCoordinator {
|
||||
})
|
||||
|
||||
if (!result.compacted) {
|
||||
this.emitInfo("No compaction needed.")
|
||||
this.emitInfo("No compaction needed.", sessionId)
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
}
|
||||
|
||||
// Restart the session with the compacted transcript. Reusing the
|
||||
// sessionId keeps the task identity (history item, task header) stable;
|
||||
// replaceActiveSession waits for the old session's stop before starting
|
||||
// the replacement (same sequencing as a mode rebuild).
|
||||
config.sessionId = sessionId
|
||||
const startInput = this.options.buildStartSessionInput(config, { cwd, mode })
|
||||
const rebuildResult = await this.options.sessions.replaceActiveSession({
|
||||
startInput,
|
||||
initialMessages: result.messages as InitialMessages,
|
||||
disposeReason: "compactTask",
|
||||
})
|
||||
if (!rebuildResult) {
|
||||
this.emitInfo("Compaction could not be applied because the session was replaced.")
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
if (!result.compactionState) {
|
||||
throw new Error("Compaction did not return durable state.")
|
||||
}
|
||||
const persisted = await sdkHost.updateSessionCompactionState(sessionId, result.compactionState)
|
||||
if (!persisted.updated) {
|
||||
throw new Error("Compaction sidecar could not be persisted.")
|
||||
}
|
||||
|
||||
const { startResult } = rebuildResult
|
||||
const task = this.options.getTask()
|
||||
if (task && task.taskId !== startResult.sessionId) {
|
||||
task.taskId = startResult.sessionId
|
||||
}
|
||||
|
||||
// Fence the conversation boundary so any straggler events from the old
|
||||
// session carry an older epoch and are dropped by the webview.
|
||||
this.options.resetMessageTranslator()
|
||||
|
||||
this.emitInfo(this.formatCompactionStatus(messagesBefore, result.messages.length))
|
||||
this.emitInfo(this.formatCompactionStatus(messagesBefore, result.messages.length), sessionId)
|
||||
await this.options.postStateToWebview()
|
||||
|
||||
Logger.log(
|
||||
`[SdkController] Compacted session ${sessionId}: ${messagesBefore} -> ${result.messages.length} messages (new session ${startResult.sessionId})`,
|
||||
)
|
||||
Logger.log(`[SdkController] Compacted session ${sessionId}: ${messagesBefore} -> ${result.messages.length} messages`)
|
||||
}
|
||||
|
||||
private getCurrentMode(): Mode {
|
||||
@@ -168,8 +148,13 @@ export class SdkCompactionCoordinator {
|
||||
return `Compacted ${messagesBefore} messages to ${messagesAfter}.`
|
||||
}
|
||||
|
||||
private emitInfo(text: string): void {
|
||||
const sessionId = this.options.sessions.getActiveSession()?.sessionId ?? ""
|
||||
private emitInfo(text: string, sessionId?: string): void {
|
||||
const activeSessionId = this.options.sessions.getActiveSession()?.sessionId
|
||||
if (sessionId && activeSessionId !== sessionId) {
|
||||
Logger.warn(`[SdkController] compactTask: skipped info for inactive session ${sessionId}`)
|
||||
return
|
||||
}
|
||||
const targetSessionId = sessionId ?? activeSessionId ?? ""
|
||||
const infoMessage: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
@@ -179,7 +164,7 @@ export class SdkCompactionCoordinator {
|
||||
}
|
||||
this.options.messages.appendAndEmit([infoMessage], {
|
||||
type: "status",
|
||||
payload: { sessionId, status: "running" },
|
||||
payload: { sessionId: targetSessionId, status: "running" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { compactSessionMessages } from "./sdk-compaction"
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const createContextCompactionPrepareTurn = vi.fn()
|
||||
const createSessionCompactionState = vi.fn((input: unknown) => ({ version: 1, input }))
|
||||
vi.mock("@cline/core", () => ({
|
||||
createContextCompactionPrepareTurn: (...args: unknown[]) => createContextCompactionPrepareTurn(...args),
|
||||
createSessionCompactionState: (input: unknown) => createSessionCompactionState(input),
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: { debug: vi.fn(), error: vi.fn(), log: vi.fn(), warn: vi.fn() },
|
||||
}))
|
||||
|
||||
let compactSessionMessages: typeof import("./sdk-compaction").compactSessionMessages
|
||||
|
||||
const baseConfig = {
|
||||
providerConfig: { providerId: "anthropic", modelId: "claude" },
|
||||
providerId: "anthropic",
|
||||
@@ -21,6 +24,10 @@ const baseConfig = {
|
||||
} as unknown as Parameters<typeof compactSessionMessages>[0]["config"]
|
||||
|
||||
describe("compactSessionMessages", () => {
|
||||
beforeAll(async () => {
|
||||
;({ compactSessionMessages } = await import("./sdk-compaction"))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -33,7 +40,9 @@ describe("compactSessionMessages", () => {
|
||||
})
|
||||
|
||||
it("builds a manual-mode prepareTurn and force-enables compaction", async () => {
|
||||
const compact = vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] })
|
||||
const compact = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messages: [{ role: "user", content: "summary" }], systemPrompt: "rewritten system" })
|
||||
createContextCompactionPrepareTurn.mockReturnValueOnce(compact)
|
||||
|
||||
const messages = [
|
||||
@@ -53,7 +62,17 @@ describe("compactSessionMessages", () => {
|
||||
{ mode: "manual" },
|
||||
)
|
||||
expect(compact).toHaveBeenCalledOnce()
|
||||
expect(result).toEqual({ compacted: true, messages: [{ role: "user", content: "summary" }] })
|
||||
expect(createSessionCompactionState).toHaveBeenCalledWith({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "s1",
|
||||
systemPrompt: "rewritten system",
|
||||
})
|
||||
expect(result).toEqual({
|
||||
compacted: true,
|
||||
messages: [{ role: "user", content: "summary" }],
|
||||
compactionState: { version: 1, input: expect.anything() },
|
||||
})
|
||||
})
|
||||
|
||||
it("returns compacted=false when prepareTurn is unavailable", async () => {
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
// apps/cli/src/runtime/interactive/compaction.ts (`compactInteractiveMessages`):
|
||||
// it builds a manual-mode compaction `prepareTurn` via the SDK's
|
||||
// `createContextCompactionPrepareTurn` and runs it against the current session
|
||||
// transcript, returning the compacted messages.
|
||||
// transcript, returning the compacted working-context sidecar state.
|
||||
//
|
||||
// The CLI then restarts the session with the compacted messages; the VSCode
|
||||
// adapter does the same in SdkCompactionCoordinator. Keeping the actual
|
||||
// compaction effect in the SDK (rather than asking the model to "summarize the
|
||||
// conversation") is what makes the compact button real instead of improvised.
|
||||
// The VSCode coordinator persists that sidecar without replacing the canonical
|
||||
// transcript, so the active session and later resumes use compacted working
|
||||
// context while saved messages remain intact.
|
||||
|
||||
import { type CoreSessionConfig, createContextCompactionPrepareTurn } from "@cline/core"
|
||||
import {
|
||||
type CoreSessionConfig,
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "@cline/core"
|
||||
import type { Message as SdkMessage, ModelInfo as SdkModelInfo } from "@cline/llms"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
@@ -35,6 +39,7 @@ export interface CompactSessionMessagesInput {
|
||||
export interface CompactSessionMessagesResult {
|
||||
compacted: boolean
|
||||
messages: SdkMessage[]
|
||||
compactionState?: SessionCompactionState
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,5 +108,14 @@ export async function compactSessionMessages(input: CompactSessionMessagesInput)
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages }
|
||||
}
|
||||
return { compacted: true, messages: result.messages }
|
||||
return {
|
||||
compacted: true,
|
||||
messages: result.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ describe("SdkFollowupCoordinator", () => {
|
||||
const task = makeTask("task-1")
|
||||
const { coordinator, options } = makeCoordinator({ task })
|
||||
options.sessionConfigBuilder.build.mockRejectedValue(new Error("missing api key"))
|
||||
options.isClineProviderActive.mockReturnValue(true)
|
||||
options.isClineManagedProviderActive.mockReturnValue(true)
|
||||
|
||||
await coordinator.askResponse("continue")
|
||||
|
||||
@@ -383,7 +383,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
loadInitialMessages: vi.fn().mockResolvedValue([{ role: "user", content: "hello" }]),
|
||||
buildStartSessionInput: vi.fn(() => ({ prompt: "start" })),
|
||||
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
|
||||
isClineProviderActive: vi.fn(() => false),
|
||||
isClineManagedProviderActive: vi.fn(() => false),
|
||||
emitClineAuthError: vi.fn(),
|
||||
resetMessageTranslator: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -416,7 +416,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
getWorkspaceRoot: ReturnType<typeof vi.fn>
|
||||
loadInitialMessages: ReturnType<typeof vi.fn>
|
||||
resolveContextMentions: ReturnType<typeof vi.fn>
|
||||
isClineProviderActive: ReturnType<typeof vi.fn>
|
||||
isClineManagedProviderActive: ReturnType<typeof vi.fn>
|
||||
emitClineAuthError: ReturnType<typeof vi.fn>
|
||||
resetMessageTranslator: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface SdkFollowupCoordinatorOptions {
|
||||
loadInitialMessages: (sessionHost: SdkSessionHost, taskId: string) => Promise<unknown[] | undefined>
|
||||
buildStartSessionInput: (config: SessionConfig, input: { cwd: string; mode: Mode }) => StartInput
|
||||
resolveContextMentions: (text: string) => Promise<string>
|
||||
isClineProviderActive: () => boolean
|
||||
isClineManagedProviderActive: () => boolean
|
||||
emitClineAuthError: () => void
|
||||
resetMessageTranslator: () => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
@@ -119,7 +119,7 @@ export class SdkFollowupCoordinator {
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
const isClineAuth =
|
||||
this.options.isClineProviderActive() &&
|
||||
this.options.isClineManagedProviderActive() &&
|
||||
(errorMsg.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
|
||||
errorMsg.toLowerCase().includes("missing api key") ||
|
||||
errorMsg.toLowerCase().includes("unauthorized"))
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
// SdkPluginCommandCoordinator — discovers and executes plugin-registered
|
||||
// slash commands, mirroring the CLI's createWorkspaceChatCommandHost.
|
||||
//
|
||||
// Plugins register commands via `api.registerCommand({ name, handler })` in
|
||||
// their setup(). The ContributionRegistry runs setup() and collects the
|
||||
// registered commands. This coordinator:
|
||||
// 1. Lazily loads plugins via resolveAndLoadAgentPlugins (sandbox mode)
|
||||
// 2. Initializes a ContributionRegistry to run setup() and gather commands
|
||||
// 3. Exposes getSlashCommands() for autocomplete
|
||||
// 4. Exposes resolveCommand(text) to execute a /command and return its result
|
||||
|
||||
import {
|
||||
type AgentExtensionCommand,
|
||||
type AgentExtensionCommandResult,
|
||||
createContributionRegistry,
|
||||
noopBasicLogger,
|
||||
resolveAndLoadAgentPlugins,
|
||||
} from "@cline/core"
|
||||
import type { AgentTool, Message } from "@cline/shared"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
|
||||
export interface PluginSlashCommand {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PluginCommandResult {
|
||||
reply?: string
|
||||
submitPrompt?: string
|
||||
}
|
||||
|
||||
interface LoadedPlugins {
|
||||
commands: AgentExtensionCommand[]
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
|
||||
export class SdkPluginCommandCoordinator {
|
||||
private loadedPromise: Promise<LoadedPlugins | undefined> | undefined
|
||||
|
||||
/**
|
||||
* Lazily load plugins and initialize the contribution registry. The result
|
||||
* is cached so subsequent calls reuse the same sandbox process. Returns
|
||||
* undefined if no plugins are installed or loading fails.
|
||||
*/
|
||||
private ensureLoaded(): Promise<LoadedPlugins | undefined> {
|
||||
if (this.loadedPromise) {
|
||||
return this.loadedPromise
|
||||
}
|
||||
this.loadedPromise = (async () => {
|
||||
let loaded: Awaited<ReturnType<typeof resolveAndLoadAgentPlugins>>
|
||||
try {
|
||||
loaded = await resolveAndLoadAgentPlugins({
|
||||
logger: noopBasicLogger,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
Logger.warn(`[PluginCommands] Plugin loading failed; continuing without plugin commands (${message})`)
|
||||
return undefined
|
||||
}
|
||||
if (!loaded.extensions.length) {
|
||||
await loaded.shutdown?.().catch(() => {})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const registry = createContributionRegistry<(typeof loaded.extensions)[number], AgentTool, Message[]>({
|
||||
extensions: loaded.extensions,
|
||||
})
|
||||
try {
|
||||
await registry.initialize()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
Logger.warn(`[PluginCommands] Contribution registry initialization failed (${message})`)
|
||||
await loaded.shutdown?.().catch(() => {})
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
commands: registry.getRegistrySnapshot().commands,
|
||||
shutdown: async () => {
|
||||
await loaded.shutdown?.().catch(() => {})
|
||||
},
|
||||
}
|
||||
})()
|
||||
return this.loadedPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Return plugin-registered slash commands for autocomplete. Returns an
|
||||
* empty array if no plugins are installed or loading fails.
|
||||
*/
|
||||
async getSlashCommands(): Promise<PluginSlashCommand[]> {
|
||||
const loaded = await this.ensureLoaded()
|
||||
if (!loaded) {
|
||||
return []
|
||||
}
|
||||
return loaded.commands
|
||||
.filter((cmd) => typeof cmd.handler === "function")
|
||||
.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a leading /command from a plugin. Returns null if the text does
|
||||
* not match a plugin command. Returns { reply?, submitPrompt? } from the
|
||||
* command handler.
|
||||
*/
|
||||
async resolveCommand(text: string): Promise<PluginCommandResult | null> {
|
||||
if (!text.startsWith("/") || text.length < 2) {
|
||||
return null
|
||||
}
|
||||
const match = text.match(/^\/(\S+)/)
|
||||
if (!match?.[1]) {
|
||||
return null
|
||||
}
|
||||
const name = match[1]
|
||||
const remainder = text.slice(name.length + 1).trim()
|
||||
|
||||
const loaded = await this.ensureLoaded()
|
||||
if (!loaded) {
|
||||
return null
|
||||
}
|
||||
const command = loaded.commands.find((cmd) => cmd.name === name && typeof cmd.handler === "function")
|
||||
if (!command?.handler) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const result: AgentExtensionCommandResult = await command.handler(remainder)
|
||||
if (typeof result === "string") {
|
||||
return { reply: result }
|
||||
}
|
||||
return {
|
||||
reply: result.reply,
|
||||
submitPrompt: result.submitPrompt,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
Logger.warn(`[PluginCommands] Command "/${name}" failed: ${message}`)
|
||||
return { reply: `Command /${name} failed: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the plugin sandbox process. Called on extension disposal.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
const promise = this.loadedPromise
|
||||
this.loadedPromise = undefined
|
||||
if (promise) {
|
||||
const loaded = await promise.catch(() => undefined)
|
||||
await loaded?.shutdown().catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
import type { ClineApiReqInfo, TurnPhase } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineManagedProvider } from "@/shared/utils/cline"
|
||||
import type { MessageTranslatorState, TranslationResult } from "./message-translator"
|
||||
import { translateSessionEvent } from "./message-translator"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
|
||||
@@ -250,11 +251,12 @@ export class SdkSessionEventCoordinator {
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
|
||||
const provider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
if (provider !== "cline") {
|
||||
// Free models are also selectable on ClinePass — they ride usage billing at $0
|
||||
if (!isClineManagedProvider(provider)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const modelId = mode === "plan" ? apiConfig.planModeClineModelId : apiConfig.actModeClineModelId
|
||||
const modelId = this.getCurrentClineModelId()
|
||||
if (!modelId) {
|
||||
return false
|
||||
}
|
||||
@@ -283,6 +285,10 @@ export class SdkSessionEventCoordinator {
|
||||
}
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
|
||||
const provider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
if (provider === "cline-pass") {
|
||||
return mode === "plan" ? apiConfig.planModeClinePassModelId : apiConfig.actModeClinePassModelId
|
||||
}
|
||||
return mode === "plan" ? apiConfig.planModeClineModelId : apiConfig.actModeClineModelId
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
it("emits Cline auth errors when reinitialization fails due auth", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
options.sessionConfigBuilder.build.mockRejectedValue(new Error("missing api key"))
|
||||
options.isClineProviderActive.mockReturnValue(true)
|
||||
options.isClineManagedProviderActive.mockReturnValue(true)
|
||||
|
||||
await coordinator.reinitExistingTaskFromId("task-1")
|
||||
|
||||
@@ -252,7 +252,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
createTempSessionHost: vi.fn().mockResolvedValue(tempHost),
|
||||
loadInitialMessages: vi.fn().mockResolvedValue([{ role: "user", content: "hello" }]),
|
||||
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
|
||||
isClineProviderActive: vi.fn(() => false),
|
||||
isClineManagedProviderActive: vi.fn(() => false),
|
||||
emitClineAuthError: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -277,7 +277,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
createTempSessionHost: ReturnType<typeof vi.fn>
|
||||
loadInitialMessages: ReturnType<typeof vi.fn>
|
||||
resolveContextMentions: ReturnType<typeof vi.fn>
|
||||
isClineProviderActive: ReturnType<typeof vi.fn>
|
||||
isClineManagedProviderActive: ReturnType<typeof vi.fn>
|
||||
emitClineAuthError: ReturnType<typeof vi.fn>
|
||||
captureProviderApiError: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface SdkTaskStartCoordinatorOptions {
|
||||
createTempSessionHost: () => Promise<SdkSessionHost>
|
||||
loadInitialMessages: (reader: SdkSessionHost, taskId: string) => Promise<unknown[] | undefined>
|
||||
resolveContextMentions: (text: string) => Promise<string>
|
||||
isClineProviderActive: () => boolean
|
||||
isClineManagedProviderActive: () => boolean
|
||||
emitClineAuthError: (task?: string) => void
|
||||
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
@@ -140,9 +140,9 @@ export class SdkTaskStartCoordinator {
|
||||
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
|
||||
await this.options.postStateToWebview()
|
||||
|
||||
if (prompt?.trim()) {
|
||||
if (prompt?.trim() || images?.length || files?.length) {
|
||||
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
|
||||
const resolvedTask = await this.options.resolveContextMentions(prompt)
|
||||
const resolvedTask = await this.options.resolveContextMentions(prompt || "")
|
||||
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ export class SdkTaskStartCoordinator {
|
||||
|
||||
const reinitErrorMsg = error instanceof Error ? error.message : String(error)
|
||||
const isClineAuthReinit =
|
||||
this.options.isClineProviderActive() &&
|
||||
this.options.isClineManagedProviderActive() &&
|
||||
(reinitErrorMsg.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
|
||||
reinitErrorMsg.toLowerCase().includes("missing api key") ||
|
||||
reinitErrorMsg.toLowerCase().includes("unauthorized"))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RestoreResult,
|
||||
SendSessionInput,
|
||||
SessionAccumulatedUsage,
|
||||
SessionCompactionState,
|
||||
SessionHistoryRecord,
|
||||
SessionPendingPrompt,
|
||||
SessionRecord,
|
||||
@@ -33,6 +34,7 @@ export interface SdkSessionHost {
|
||||
listHistory(options?: ClineCoreListHistoryOptions): Promise<SessionHistoryRecord[]>
|
||||
delete(sessionId: string): Promise<boolean>
|
||||
readMessages(sessionId: string): Promise<SdkInitialMessages>
|
||||
updateSessionCompactionState?(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }>
|
||||
restore(input: RestoreInput): Promise<RestoreResult>
|
||||
update(
|
||||
sessionId: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type RestoreResult,
|
||||
type SendSessionInput,
|
||||
type SessionAccumulatedUsage,
|
||||
type SessionCompactionState,
|
||||
type SessionHistoryRecord,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
@@ -199,6 +200,10 @@ export class VscodeSessionHost implements SdkSessionHost {
|
||||
return this.inner.readMessages(sessionId)
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }> {
|
||||
return this.inner.updateSessionCompactionState(sessionId, state)
|
||||
}
|
||||
|
||||
async restore(input: RestoreInput): Promise<RestoreResult> {
|
||||
return this.inner.restore(input)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/llms"
|
||||
import { serializeError } from "serialize-error"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
|
||||
@@ -14,6 +15,7 @@ export enum ClineErrorType {
|
||||
QuotaExceeded = "quotaExceeded",
|
||||
Entitlement = "entitlement",
|
||||
OrgClinePassRestriction = "orgClinePassRestriction",
|
||||
ClinePassLimit = "clinePassLimit",
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
@@ -183,6 +185,13 @@ export class ClineError extends Error {
|
||||
return ClineErrorType.Entitlement
|
||||
}
|
||||
|
||||
if (
|
||||
(detailMessage ? isClinePassLimitMessage(detailMessage) : false) ||
|
||||
(rawMessage ? isClinePassLimitMessage(rawMessage) : false)
|
||||
) {
|
||||
return ClineErrorType.ClinePassLimit
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
const isAuthStatus = status !== undefined && status > 400 && status < 429
|
||||
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
|
||||
|
||||
@@ -44,5 +44,24 @@ describe("ClineError", () => {
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.OrgClinePassRestriction)
|
||||
})
|
||||
|
||||
it("should classify ClinePass period limit messages separately", () => {
|
||||
const err = new ClineError(
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
|
||||
)
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit)
|
||||
})
|
||||
|
||||
it("should classify nested ClinePass period limit messages separately", () => {
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403",
|
||||
error: {
|
||||
message: "You have reached your monthly ClinePass limit. The limit resets in 12h, please try again later.",
|
||||
},
|
||||
})
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1583,7 +1583,7 @@ export class TelemetryService {
|
||||
/**
|
||||
* Records when slash commands or workflows are activated
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param commandName The name of the command (e.g., "newtask", "newrule", or custom workflow name)
|
||||
* @param commandName The name of the command (e.g., "newtask", "reportbug", or custom workflow name)
|
||||
* @param commandType Whether it's a built-in command, custom workflow, or MCP prompt
|
||||
*/
|
||||
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow" | "mcp_prompt") {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { OnboardingModelGroup } from "./proto/cline/state"
|
||||
import { Mode } from "./storage/types"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
import type { SlashCommand } from "./slashCommands"
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
type: "grpc_response" // New type for gRPC responses
|
||||
@@ -115,8 +114,6 @@ export interface ExtensionState {
|
||||
worktreesEnabled?: ClineFeatureSetting
|
||||
customPrompt?: string
|
||||
favoritedModelIds: string[]
|
||||
/** Plugin-registered slash commands surfaced for autocomplete. */
|
||||
pluginSlashCommands?: SlashCommand[]
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: WorkspaceRoot[]
|
||||
primaryRootIndex: number
|
||||
@@ -214,6 +211,7 @@ export type ClineAsk =
|
||||
| "new_task"
|
||||
| "condense"
|
||||
| "summarize_task"
|
||||
| "report_bug"
|
||||
| "use_subagents"
|
||||
|
||||
export type ClineSay =
|
||||
@@ -277,7 +275,7 @@ export interface ClineSayTool {
|
||||
operationIsLocatedInWorkspace?: boolean
|
||||
/** Starting line numbers in the original file where each SEARCH block matched */
|
||||
startLineNumbers?: number[]
|
||||
/** Inclusive line range actually returned by read_file (for UI summaries). */
|
||||
/** One-based inclusive line range requested by read_file; readLineEnd omitted = open-ended read (for UI summaries). */
|
||||
readLineStart?: number
|
||||
readLineEnd?: number
|
||||
}
|
||||
|
||||
@@ -142,8 +142,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
"Claude Sonnet 4.5 is an Anthropic model for coding, agentic search, and AI agent workflows. It supports planning and implementation tasks across the software development lifecycle.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
|
||||
}
|
||||
|
||||
export type ClinePassModelId = keyof typeof clinePassModels
|
||||
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
|
||||
export const clinePassDefaultModelId = "cline-pass/glm-5.2"
|
||||
export const clinePassModelInfoSaneDefaults: ModelInfo = {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 128_000,
|
||||
@@ -156,21 +155,6 @@ export const clinePassModelInfoSaneDefaults: ModelInfo = {
|
||||
cacheWritesPrice: 0,
|
||||
description: "",
|
||||
}
|
||||
export const clinePassModels = {
|
||||
"cline-pass/glm-5.1": {
|
||||
name: "cline-pass/glm-5.1",
|
||||
maxTokens: 131_072,
|
||||
contextWindow: 202_752,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.98,
|
||||
outputPrice: 3.08,
|
||||
cacheReadsPrice: 0.182,
|
||||
cacheWritesPrice: 0,
|
||||
description: "",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export function getModelSlug(modelId: string): string {
|
||||
return modelId.split("/").at(-1) ?? modelId
|
||||
@@ -187,11 +171,7 @@ export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record
|
||||
}
|
||||
|
||||
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
|
||||
return (
|
||||
clinePassModels[modelId as keyof typeof clinePassModels] ??
|
||||
modelInfoByName?.[getModelSlug(modelId)] ??
|
||||
clinePassModelInfoSaneDefaults
|
||||
)
|
||||
return modelInfoByName?.[getModelSlug(modelId)] ?? clinePassModelInfoSaneDefaults
|
||||
}
|
||||
|
||||
export const openAiModelInfoSafeDefaults: OpenAiCompatibleModelInfo = {
|
||||
|
||||
@@ -24,6 +24,7 @@ function convertClineAskToProtoEnum(ask: AppClineAsk | undefined): ClineAsk | un
|
||||
new_task: ClineAsk.NEW_TASK,
|
||||
condense: ClineAsk.CONDENSE,
|
||||
summarize_task: ClineAsk.SUMMARIZE_TASK,
|
||||
report_bug: ClineAsk.REPORT_BUG,
|
||||
use_subagents: ClineAsk.USE_SUBAGENTS,
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ function convertProtoEnumToClineAsk(ask: ClineAsk): AppClineAsk | undefined {
|
||||
[ClineAsk.NEW_TASK]: "new_task",
|
||||
[ClineAsk.CONDENSE]: "condense",
|
||||
[ClineAsk.SUMMARIZE_TASK]: "summarize_task",
|
||||
[ClineAsk.REPORT_BUG]: "report_bug",
|
||||
[ClineAsk.USE_SUBAGENTS]: "use_subagents",
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ export class Logger {
|
||||
fullMessage += ` ${args.map((arg) => JSON.stringify(arg)).join(" ")}`
|
||||
}
|
||||
const errorSuffix = error?.message ? ` ${error.message}` : ""
|
||||
Logger.output(`${level} ${fullMessage}${errorSuffix}`.trimEnd())
|
||||
const ts = new Date().toISOString()
|
||||
Logger.output(`${ts} ${level} ${fullMessage}${errorSuffix}`.trimEnd())
|
||||
} catch {
|
||||
// do nothing if Logger fails
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface SlashCommand {
|
||||
name: string
|
||||
description?: string
|
||||
section?: "default" | "custom" | "mcp"
|
||||
cliCompatible?: boolean
|
||||
}
|
||||
|
||||
export const BASE_SLASH_COMMANDS: SlashCommand[] = [
|
||||
@@ -9,21 +10,31 @@ export const BASE_SLASH_COMMANDS: SlashCommand[] = [
|
||||
name: "newtask",
|
||||
description: "Create a new task with context from the current task",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "deep-planning",
|
||||
description: "Create a comprehensive implementation plan before coding",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "smol",
|
||||
description: "Condenses your current context window",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "newrule",
|
||||
description: "Create a new Cline rule based on your conversation",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "reportbug",
|
||||
description: "Create a Github issue with Cline",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum ClineDefaultTool {
|
||||
WEB_SEARCH = "web_search",
|
||||
CONDENSE = "condense",
|
||||
SUMMARIZE_TASK = "summarize_task",
|
||||
REPORT_BUG = "report_bug",
|
||||
NEW_RULE = "new_rule",
|
||||
APPLY_PATCH = "apply_patch",
|
||||
USE_SKILL = "use_skill",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function isClineProvider(provider: string | undefined) {
|
||||
export function isClineManagedProvider(provider: string | undefined) {
|
||||
return provider === "cline" || provider === "cline-pass"
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
|
||||
/**
|
||||
* Integration test for CLINE-2584: the plugin sandbox bootstrap
|
||||
* (`plugin-sandbox-bootstrap.js`) must be shipped with the VS Code extension.
|
||||
*
|
||||
* The bootstrap runs in an isolated child process spawned by
|
||||
* `SubprocessSandbox` — it cannot be inlined into `extension.js` because the
|
||||
* sandbox spawns it via `node <bootstrapFile>`. The CLI build copies this
|
||||
* file (`apps/cli/bun.mts`); the extension build (`esbuild.mjs`) must do the
|
||||
* same.
|
||||
*
|
||||
* The bootstrap also has external runtime dependencies that must be resolvable
|
||||
* from its on-disk location via Node's standard module resolution:
|
||||
* - jiti (TypeScript transpilation of .ts plugins)
|
||||
* - @cline/shared, @cline/sdk (host-provided SDK packages that plugins import)
|
||||
*
|
||||
* This test runs the real `bun esbuild.mjs` build and checks the real
|
||||
* `dist/` output, exercising the same build pipeline CI uses.
|
||||
*/
|
||||
|
||||
const projectRoot = join(import.meta.dir, "..", "..")
|
||||
const distDir = join(projectRoot, "dist")
|
||||
const bootstrapPath = join(distDir, "extensions", "plugin-sandbox-bootstrap.js")
|
||||
|
||||
describe("plugin-sandbox bootstrap build artifact (CLINE-2584)", () => {
|
||||
it("esbuild.mjs emits plugin-sandbox-bootstrap.js into dist/", async () => {
|
||||
const result = await $`bun esbuild.mjs`.cwd(projectRoot).quiet()
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
expect(existsSync(join(distDir, "extension.js"))).toBe(true)
|
||||
expect(existsSync(bootstrapPath)).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
it("the bootstrap is a real executable script with IPC handling", async () => {
|
||||
expect(existsSync(bootstrapPath)).toBe(true)
|
||||
|
||||
const content = await readFile(bootstrapPath, "utf8")
|
||||
expect(content.length).toBeGreaterThan(1000)
|
||||
expect(content).toMatch(/process\.on\(.process\.message|process\.send|type:\s*["']response["']/)
|
||||
}, 60_000)
|
||||
|
||||
it("the bootstrap's runtime dependencies resolve from dist/", () => {
|
||||
expect(existsSync(bootstrapPath)).toBe(true)
|
||||
|
||||
// The bootstrap is spawned as a standalone Node child process. It
|
||||
// imports jiti (for TypeScript transpilation) and @cline/shared as
|
||||
// external modules, and plugins import @cline/sdk. Node resolves
|
||||
// these by walking up from the bootstrap's directory. All must be
|
||||
// direct dependencies of the extension so they appear in
|
||||
// node_modules and are resolvable.
|
||||
const requireFromBootstrap = createRequire(bootstrapPath)
|
||||
expect(() => requireFromBootstrap.resolve("jiti")).not.toThrow()
|
||||
expect(() => requireFromBootstrap.resolve("@cline/shared")).not.toThrow()
|
||||
// @cline/sdk is a host-provided SDK specifier that plugins import.
|
||||
// The bootstrap's findHostPackageRoot walks up from dist/extensions/
|
||||
// looking for node_modules/@cline/sdk/package.json.
|
||||
expect(
|
||||
existsSync(join(projectRoot, "node_modules", "@cline", "sdk", "package.json")),
|
||||
).toBe(true)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "../core/controller"
|
||||
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands"
|
||||
import { EmptyRequest } from "../shared/proto/cline/common"
|
||||
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands"
|
||||
|
||||
/**
|
||||
* Unit tests for getAvailableSlashCommands RPC endpoint
|
||||
* Tests the slash command discovery and filtering functionality
|
||||
*/
|
||||
describe("getAvailableSlashCommands", () => {
|
||||
let mockController: Partial<Controller>
|
||||
let mockStateManager: {
|
||||
getWorkspaceStateKey: sinon.SinonStub
|
||||
getGlobalSettingsKey: sinon.SinonStub
|
||||
getGlobalStateKey: sinon.SinonStub
|
||||
getRemoteConfigSettings: sinon.SinonStub
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockStateManager = {
|
||||
getWorkspaceStateKey: sinon.stub(),
|
||||
getGlobalSettingsKey: sinon.stub(),
|
||||
getGlobalStateKey: sinon.stub(),
|
||||
getRemoteConfigSettings: sinon.stub(),
|
||||
}
|
||||
|
||||
// Default stubs return empty/null values
|
||||
mockStateManager.getWorkspaceStateKey.returns(null)
|
||||
mockStateManager.getGlobalSettingsKey.returns(null)
|
||||
mockStateManager.getGlobalStateKey.returns(null)
|
||||
mockStateManager.getRemoteConfigSettings.returns(null)
|
||||
|
||||
mockController = {
|
||||
stateManager: mockStateManager as any,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("Base Slash Commands", () => {
|
||||
it("should return all base slash commands", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should have at least all base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
|
||||
// Verify each base command is present
|
||||
for (const baseCmd of BASE_SLASH_COMMANDS) {
|
||||
const found = response.commands.find((cmd) => cmd.name === baseCmd.name)
|
||||
found!.should.not.be.undefined()
|
||||
found!.description.should.equal(baseCmd.description)
|
||||
found!.section.should.equal("default")
|
||||
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false)
|
||||
}
|
||||
})
|
||||
|
||||
it("should not include the deprecated subagent slash command", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
const deprecatedCommand = response.commands.find((cmd) => cmd.name === "subagent")
|
||||
;(deprecatedCommand === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should mark base commands with section 'default'", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name)
|
||||
for (const cmd of response.commands) {
|
||||
if (baseCommandNames.includes(cmd.name)) {
|
||||
cmd.section.should.equal("default")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Local Workflow Toggles", () => {
|
||||
it("should include enabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/my-workflow.md": true,
|
||||
"/path/to/another-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
|
||||
myWorkflow!.should.not.be.undefined()
|
||||
myWorkflow!.section.should.equal("custom")
|
||||
myWorkflow!.cliCompatible.should.equal(true)
|
||||
|
||||
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow.md")
|
||||
anotherWorkflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should exclude disabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/enabled-workflow.md": true,
|
||||
"/path/to/disabled-workflow.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
|
||||
enabled!.should.not.be.undefined()
|
||||
|
||||
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
|
||||
;(disabled === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should extract filename from full path", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should handle Windows-style paths", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Global Workflow Toggles", () => {
|
||||
it("should include enabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/global-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
|
||||
it("should exclude disabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/disabled-global.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Workflow Deduplication", () => {
|
||||
it("should prefer local workflows over global workflows with same name", async () => {
|
||||
// Same filename in both local and global
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/local/path/shared-workflow.md": true,
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/shared-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only appear once
|
||||
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
|
||||
matches.length.should.equal(1)
|
||||
})
|
||||
|
||||
it("should include global workflow if local with same name is disabled", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/local/path/shared-workflow.md": false, // disabled locally
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/shared-workflow.md": true, // enabled globally
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Global should appear since local is disabled
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Remote Workflows", () => {
|
||||
it("should include alwaysEnabled remote workflows", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "always-on-workflow", alwaysEnabled: true }],
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "always-on-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
|
||||
it("should include remote workflows enabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "toggle-workflow", alwaysEnabled: false }],
|
||||
})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
|
||||
"toggle-workflow": true, // not explicitly disabled
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "toggle-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should exclude remote workflows explicitly disabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "disabled-remote", alwaysEnabled: false }],
|
||||
})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
|
||||
"disabled-remote": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-remote")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should include remote workflows by default if not explicitly disabled", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "default-enabled", alwaysEnabled: false }],
|
||||
})
|
||||
// No toggle entry for this workflow
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "default-enabled")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle null/undefined state values gracefully", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.returns(null)
|
||||
mockStateManager.getGlobalSettingsKey.returns(undefined)
|
||||
mockStateManager.getGlobalStateKey.returns(null)
|
||||
mockStateManager.getRemoteConfigSettings.returns(null)
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should still return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
|
||||
it("should handle empty workflow toggle objects", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [],
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only have base commands
|
||||
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
|
||||
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should not throw, just return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
|
||||
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
|
||||
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
|
||||
"src/core/controller/models/__tests__/refreshGroqModels.test.ts",
|
||||
],
|
||||
environment: "node",
|
||||
// Several suites lazily `await import()` their subject inside the first test
|
||||
|
||||
@@ -780,6 +780,15 @@ export const CondenseConversation = quickStory(
|
||||
"Would you like me to condense the conversation to improve performance?",
|
||||
"Shows utility action to condense conversation for better performance.",
|
||||
)
|
||||
export const ReportBug = quickStory(
|
||||
"Report Bug",
|
||||
"report_bug",
|
||||
JSON.stringify({
|
||||
steps_to_reproduce: "1. Open Cline\n2. Start a new task\n3. Observe the error",
|
||||
what_happened: "Cline crashes unexpectedly",
|
||||
}),
|
||||
"Shows utility action to report bugs to the GitHub repository.",
|
||||
)
|
||||
export const ResumeCompletedTask = quickStory(
|
||||
"Resume Completed Task type",
|
||||
"resume_completed_task",
|
||||
|
||||
@@ -55,6 +55,7 @@ import { MarkdownRow } from "./MarkdownRow"
|
||||
import NewTaskPreview from "./NewTaskPreview"
|
||||
import PlanCompletionOutputRow from "./PlanCompletionOutputRow"
|
||||
import QuoteButton from "./QuoteButton"
|
||||
import ReportBugPreview from "./ReportBugPreview"
|
||||
import { RequestStartRow } from "./RequestStartRow"
|
||||
import SearchResultsDisplay from "./SearchResultsDisplay"
|
||||
import SubagentStatusRow from "./SubagentStatusRow"
|
||||
@@ -506,10 +507,11 @@ export const ChatRowContent = memo(
|
||||
{tool.path && !tool.path.startsWith(".") && <span>/</span>}
|
||||
<span className="ph-no-capture whitespace-nowrap overflow-hidden text-ellipsis mr-2 text-left [direction: rtl]">
|
||||
{cleanPathPrefix(tool.path ?? "") + "\u200E"}
|
||||
{tool.readLineStart != null && tool.readLineEnd != null ? (
|
||||
{tool.readLineStart != null ? (
|
||||
<span className="opacity-80">
|
||||
{" "}
|
||||
({tool.readLineStart}-{tool.readLineEnd})
|
||||
({tool.readLineStart}
|
||||
{tool.readLineEnd != null ? `-${tool.readLineEnd}` : "+"})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -1158,6 +1160,16 @@ export const ChatRowContent = memo(
|
||||
<NewTaskPreview context={message.text || ""} />
|
||||
</div>
|
||||
)
|
||||
case "report_bug":
|
||||
return (
|
||||
<div>
|
||||
<div className={HEADER_CLASSNAMES}>
|
||||
<FilePlus2Icon className="size-2" />
|
||||
<span className="text-foreground font-bold">Cline wants to create a Github issue:</span>
|
||||
</div>
|
||||
<ReportBugPreview data={message.text || ""} />
|
||||
</div>
|
||||
)
|
||||
case "plan_mode_respond": {
|
||||
let response: string | undefined
|
||||
let options: string[] | undefined
|
||||
|
||||
@@ -224,7 +224,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteConfigSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
} = useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
@@ -490,7 +489,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
|
||||
if (allCommands.length === 0) {
|
||||
@@ -516,7 +514,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
if (commands.length > 0) {
|
||||
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
|
||||
@@ -676,7 +673,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
slashCommandsQuery,
|
||||
handleSlashCommandsSelect,
|
||||
sendingDisabled,
|
||||
pluginSlashCommands,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -988,8 +984,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
|
||||
if (isValidCommand) {
|
||||
@@ -1003,14 +997,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
highlightLayerRef.current.innerHTML = processedText
|
||||
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
|
||||
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
|
||||
}, [
|
||||
localWorkflowToggles,
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
])
|
||||
}, [localWorkflowToggles, globalWorkflowToggles, remoteWorkflowToggles, remoteConfigSettings])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHighlights()
|
||||
@@ -1131,6 +1118,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
switch (selectedProvider) {
|
||||
case "cline":
|
||||
return `${selectedProvider}:${selectedModelId}`
|
||||
case "cline-pass":
|
||||
// Free models selected on ClinePass go through Cline usage billing,
|
||||
// so label them the same way as the cline provider
|
||||
return selectedModelId.startsWith("cline-pass/")
|
||||
? `${selectedProvider}:${selectedModelId.replace(/^cline-pass\//, "")}`
|
||||
: `cline:${selectedModelId}`
|
||||
case "openai":
|
||||
return `openai-compat:${selectedModelId}`
|
||||
case "vscode-lm":
|
||||
@@ -1413,7 +1406,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
mcpServers={mcpServers}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
onSelect={handleSlashCommandsSelect}
|
||||
pluginSlashCommands={pluginSlashCommands}
|
||||
query={slashCommandsQuery}
|
||||
remoteWorkflows={remoteConfigSettings?.remoteGlobalWorkflows}
|
||||
remoteWorkflowToggles={remoteWorkflowToggles}
|
||||
@@ -1656,6 +1648,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
"pt-0.5 pb-px px-2 z-10 text-xs w-1/2 text-center bg-transparent",
|
||||
mode === m.toLowerCase() ? "text-white" : "text-input-foreground",
|
||||
)}
|
||||
key={m}
|
||||
onMouseLeave={() => setShownTooltipMode(null)}
|
||||
onMouseOver={() => setShownTooltipMode(m.toLowerCase() === "plan" ? "plan" : "act")}
|
||||
role="switch">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user