mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca22ccf4a1 | |||
| fe2ceeaf97 | |||
| 634cc8ac9a | |||
| 70d2088306 | |||
| cf6dbdc779 | |||
| efc28486fd | |||
| 90c427740d | |||
| e6028168f2 | |||
| c3f75b3ff0 | |||
| 88ce3e0b11 | |||
| dd719dce86 |
@@ -85,3 +85,10 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
|
||||
@@ -1013,6 +1013,80 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
|
||||
// CLINE-2406: when persisted Cline auth includes an accountId, the
|
||||
// runtime path must call identifyTelemetryAccount(accountContext) so
|
||||
// subsequent task.* and workspace.* events carry user_id.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "usr-abc-123",
|
||||
provider: "cline",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
|
||||
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
|
||||
// identifyTelemetryAccount should not be called from the runtime path.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
// no auth / no accountId
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
|
||||
// CLINE-2406: identity identification from saved settings only applies
|
||||
// to Cline-provider sessions; other providers use different auth flows.
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "openrouter",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs kanban before loading runtime modules", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "kanban"];
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
getCliTelemetryService,
|
||||
identifyTelemetryAccount,
|
||||
} from "./utils/telemetry";
|
||||
import type { Config } from "./utils/types";
|
||||
import { runConnectWizard } from "./wizards/connect";
|
||||
@@ -962,6 +963,19 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
let selectedProviderSettings =
|
||||
providerSettingsManager.getProviderSettings(provider);
|
||||
|
||||
// Apply locally persisted Cline account identity so subsequent events
|
||||
// (task.*, workspace.initialized) carry user_id when available.
|
||||
// Note: user.extension_activated fires anonymously earlier in startup
|
||||
// and cannot be retroactively updated; this is by design for
|
||||
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
|
||||
if (provider === "cline") {
|
||||
const savedAccountId = selectedProviderSettings?.auth?.accountId;
|
||||
if (savedAccountId) {
|
||||
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
|
||||
}
|
||||
}
|
||||
|
||||
const persistedApiKey = getPersistedProviderApiKey(
|
||||
provider,
|
||||
selectedProviderSettings,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
@@ -103,7 +104,9 @@ export async function handleDesktopCommand(
|
||||
): Promise<unknown> {
|
||||
if (command === "list_provider_catalog") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
@@ -165,6 +168,11 @@ export async function handleDesktopCommand(
|
||||
providerId,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(providerSettingsManager, providerId, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
@@ -99,7 +100,9 @@ export async function sendProviderCatalog(
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
ctx.send(peer, {
|
||||
type: "provider_catalog",
|
||||
providers: payload.providers,
|
||||
@@ -138,6 +141,11 @@ export async function runProviderOAuthLogin(
|
||||
normalized,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== normalized) {
|
||||
markLocalProviderEnabled(providerSettingsManager, normalized, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
providerId: normalized,
|
||||
|
||||
@@ -258,8 +258,8 @@ export function SettingsView({
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -386,7 +386,7 @@ export function SettingsView({
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
usesOAuth(selectedProvider)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -13,8 +13,51 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Shareable Desktop Packages
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
- macOS: `bun run package:desktop:mac`
|
||||
- Windows: `bun run package:desktop:windows`
|
||||
- Linux: `bun run package:desktop:linux`
|
||||
|
||||
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
|
||||
|
||||
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
|
||||
|
||||
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
|
||||
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
|
||||
|
||||
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
|
||||
|
||||
### macOS signing & notarization, step by step
|
||||
|
||||
One-time keychain setup:
|
||||
|
||||
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
|
||||
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
|
||||
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
|
||||
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
|
||||
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
|
||||
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
|
||||
|
||||
Per-build:
|
||||
|
||||
```bash
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
|
||||
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
|
||||
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
|
||||
export APPLE_API_ISSUER="<issuer UUID>"
|
||||
bun run package:desktop:mac
|
||||
```
|
||||
|
||||
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
|
||||
|
||||
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
Startup flow:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
@@ -10,6 +10,11 @@
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type DesktopPlatform = "mac" | "windows" | "linux";
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
);
|
||||
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
|
||||
|
||||
process.chdir(APP_ROOT);
|
||||
|
||||
const validateArgs = (): void => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`missing value for ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
|
||||
throw new Error(
|
||||
suggestion
|
||||
? `unknown option ${arg}. Did you mean ${suggestion}?`
|
||||
: `unknown option ${arg}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argument ${arg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getArgValue = (name: string): string | undefined => {
|
||||
const prefix = `${name}=`;
|
||||
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) {
|
||||
return inline.slice(prefix.length);
|
||||
}
|
||||
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index >= 0) {
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasArg = (name: string): boolean => process.argv.includes(name);
|
||||
|
||||
const hostPlatform = (): DesktopPlatform => {
|
||||
if (process.platform === "darwin") {
|
||||
return "mac";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "windows";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return "linux";
|
||||
}
|
||||
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
|
||||
};
|
||||
|
||||
const resolveRequestedPlatform = (): DesktopPlatform => {
|
||||
const platform =
|
||||
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
|
||||
if (platform === "current") {
|
||||
return hostPlatform();
|
||||
}
|
||||
if (platform === "mac" || platform === "windows" || platform === "linux") {
|
||||
return platform;
|
||||
}
|
||||
throw new Error(
|
||||
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeName = (value: string): string =>
|
||||
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
|
||||
|
||||
const packageVersion = async (): Promise<string> => {
|
||||
const packageJson = await Bun.file(
|
||||
path.join(APP_ROOT, "package.json"),
|
||||
).json();
|
||||
return String(packageJson.version ?? "0.0.0");
|
||||
};
|
||||
|
||||
const macDistributionCredentialsConfigured = (): boolean => {
|
||||
const hasCertificate = Boolean(
|
||||
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
|
||||
);
|
||||
const hasAppleIdNotarization = Boolean(
|
||||
process.env.APPLE_ID &&
|
||||
process.env.APPLE_PASSWORD &&
|
||||
process.env.APPLE_TEAM_ID,
|
||||
);
|
||||
const hasApiKeyNotarization = Boolean(
|
||||
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
|
||||
process.env.APPLE_API_KEY_ID &&
|
||||
process.env.APPLE_API_ISSUER,
|
||||
);
|
||||
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
|
||||
};
|
||||
|
||||
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
|
||||
const host = hostPlatform();
|
||||
if (platform !== host) {
|
||||
throw new Error(
|
||||
[
|
||||
`cannot build ${platform} desktop bundles from ${host}.`,
|
||||
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
|
||||
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
|
||||
if (hostPlatform() !== "mac") {
|
||||
return;
|
||||
}
|
||||
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
|
||||
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
|
||||
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
|
||||
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
|
||||
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
|
||||
const walkFiles = (root: string): string[] => {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = path.join(root, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
paths.push(...walkFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
paths.push(fullPath);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const copyArtifact = (source: string, outputName: string): string => {
|
||||
const destination = path.join(PACKAGE_ROOT, outputName);
|
||||
rmSync(destination, { force: true, recursive: true });
|
||||
cpSync(source, destination, { recursive: true });
|
||||
return destination;
|
||||
};
|
||||
|
||||
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --force --deep --sign - ${appPath}`;
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const verifySignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`spctl --assess --type execute --verbose ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const collectMacArtifacts = async (
|
||||
version: string,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
|
||||
if (!existsSync(appPath)) {
|
||||
throw new Error(`macOS app bundle was not created at ${appPath}`);
|
||||
}
|
||||
|
||||
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
|
||||
console.warn(
|
||||
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
|
||||
);
|
||||
await signUnsignedMacApp(appPath);
|
||||
} else {
|
||||
await verifySignedMacApp(appPath);
|
||||
}
|
||||
|
||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
const suffix =
|
||||
allowUnsignedMac && !macDistributionCredentialsConfigured()
|
||||
? "-local-unsigned"
|
||||
: "";
|
||||
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
|
||||
const zipPath = path.join(PACKAGE_ROOT, zipName);
|
||||
rmSync(zipPath, { force: true });
|
||||
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
|
||||
|
||||
const artifacts = [zipPath];
|
||||
if (!suffix) {
|
||||
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
|
||||
(file) => file.endsWith(".dmg"),
|
||||
)) {
|
||||
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
};
|
||||
|
||||
const collectWindowsArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectLinuxArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.endsWith(".AppImage") ||
|
||||
file.endsWith(".deb") ||
|
||||
file.endsWith(".rpm"),
|
||||
)
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectArtifacts = async (
|
||||
platform: DesktopPlatform,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const version = await packageVersion();
|
||||
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
|
||||
mkdirSync(PACKAGE_ROOT, { recursive: true });
|
||||
|
||||
if (platform === "mac") {
|
||||
return collectMacArtifacts(version, allowUnsignedMac);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsArtifacts();
|
||||
}
|
||||
return collectLinuxArtifacts();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
validateArgs();
|
||||
|
||||
const platform = resolveRequestedPlatform();
|
||||
const allowUnsignedMac =
|
||||
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
|
||||
const skipBuild = hasArg("--skip-build");
|
||||
|
||||
assertCanBuildPlatform(platform);
|
||||
if (platform === "mac") {
|
||||
assertMacDistributionReady(allowUnsignedMac);
|
||||
}
|
||||
|
||||
if (!skipBuild) {
|
||||
await $`bun run build:binary`;
|
||||
}
|
||||
|
||||
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(
|
||||
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Packaged ${platform} desktop artifacts:`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionConnectionUpdate } from "./chat-session";
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinking")).toBe(false);
|
||||
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears reasoning settings when thinking is explicitly disabled", () => {
|
||||
expect(
|
||||
buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
reasoningEffort: null,
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates explicit reasoning settings without clearing omitted settings", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -103,7 +107,40 @@ function isoTimestampToMs(
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readReasoningEffort(
|
||||
value: unknown,
|
||||
): "low" | "medium" | "high" | "xhigh" | undefined {
|
||||
if (
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return {
|
||||
sessionId: config.sessionId ?? config.session_id,
|
||||
providerId: config.provider ?? config.providerId ?? "",
|
||||
@@ -125,6 +162,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
config.enableAgentTeams ??
|
||||
config.enable_teams ??
|
||||
false,
|
||||
...(thinking !== undefined ? { thinking } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
teamName: config.teamName ?? config.team_name,
|
||||
missionLogIntervalSteps:
|
||||
config.missionStepInterval ?? config.missionLogIntervalSteps,
|
||||
@@ -136,6 +176,63 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
const updates: SessionConnectionUpdate = {};
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
if (providerId) {
|
||||
updates.providerId = providerId;
|
||||
}
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
if (modelId) {
|
||||
updates.modelId = modelId;
|
||||
}
|
||||
const apiKey =
|
||||
typeof config.apiKey === "string"
|
||||
? config.apiKey.trim()
|
||||
: typeof config.api_key === "string"
|
||||
? config.api_key.trim()
|
||||
: undefined;
|
||||
if (apiKey) {
|
||||
updates.apiKey = apiKey;
|
||||
}
|
||||
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
|
||||
updates.baseUrl = config.baseUrl.trim();
|
||||
}
|
||||
if (config.headers && typeof config.headers === "object") {
|
||||
updates.headers = config.headers as Record<string, string>;
|
||||
}
|
||||
if (config.providerConfig && typeof config.providerConfig === "object") {
|
||||
updates.providerConfig =
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"];
|
||||
}
|
||||
if (thinking === false) {
|
||||
updates.thinking = false;
|
||||
updates.reasoningEffort = null;
|
||||
updates.thinkingBudgetTokens = null;
|
||||
return updates;
|
||||
}
|
||||
if (thinking === true) {
|
||||
updates.thinking = true;
|
||||
}
|
||||
if (reasoningEffort) {
|
||||
updates.thinking = true;
|
||||
updates.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
if (thinkingBudgetTokens !== undefined) {
|
||||
updates.thinking = true;
|
||||
updates.thinkingBudgetTokens = thinkingBudgetTokens;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -363,6 +460,13 @@ async function handleSend(
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective delivery mode.
|
||||
// When the session is busy and no explicit delivery was requested, queue it
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -35,13 +36,26 @@ import {
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
findArtifactUnderDir,
|
||||
readSessionManifest,
|
||||
@@ -945,7 +959,7 @@ export async function handleCommand(
|
||||
if (command === "list_provider_catalog") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
await ensureCustomProvidersLoaded(manager);
|
||||
return await listLocalProviders(manager);
|
||||
return await listLocalProviders(manager, { isClinePassEnabled: true });
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -1025,12 +1039,45 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Global settings ────────────────────────────────────────────────
|
||||
if (command === "get_global_settings") {
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_telemetry_opt_out") {
|
||||
if (typeof args?.telemetry_opt_out !== "boolean") {
|
||||
throw new Error("telemetry_opt_out must be a boolean");
|
||||
}
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return await startConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
if (command === "stop_connector_channel") {
|
||||
return await stopConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
|
||||
// ── MCP server management ─────────────────────────────────────────
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
@@ -1156,6 +1203,26 @@ export async function handleCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(ctx.workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type WebviewConnectorChannelsResponse = {
|
||||
available: WebviewConnectorChannel[];
|
||||
active: ReturnType<typeof listActiveConnectors>;
|
||||
};
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath =
|
||||
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
type: platform.type,
|
||||
hint: platform.hint,
|
||||
fields: platform.fields.map((field) => ({
|
||||
flag: field.flag,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
prompt: platform.security.prompt,
|
||||
fields: platform.security.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
requiredMessage: field.requiredMessage,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { available, active: listActiveConnectors() };
|
||||
}
|
||||
|
||||
async function runCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
): Promise<{
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve(exitCode ?? 0));
|
||||
});
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function waitForConnectorState(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`connector did not reach expected state within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(platform.id)) {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
() =>
|
||||
!listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import type { SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -14,7 +17,18 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
@@ -39,8 +53,20 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -57,6 +83,15 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -67,11 +102,20 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
@@ -148,6 +192,8 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
|
||||
@@ -5,10 +5,13 @@ import { dirname } from "node:path";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type CoreSessionEvent,
|
||||
NodeHubClient,
|
||||
resolveHubOwnerContext,
|
||||
type RuntimeCapabilities,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -386,6 +389,7 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
@@ -430,6 +434,12 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -682,10 +692,17 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(`code-sidecar:${process.pid}:${randomUUID()}`),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
backendMode: "hub",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -703,6 +720,7 @@ export async function initializeSessionManager(
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
@@ -716,5 +734,6 @@ export async function initializeSessionManager(
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir as osHomedir, platform } from "node:os";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
|
||||
|
||||
type MarketplaceEnvVar = {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallInput = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name?: string;
|
||||
install: {
|
||||
args?: string[];
|
||||
env?: MarketplaceEnvVar[];
|
||||
command?: string;
|
||||
notes?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type MarketplaceInstallResult = {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
status: "installed" | "uninstalled";
|
||||
message: string;
|
||||
details?: JsonRecord;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallStatusResult = {
|
||||
installedKeys: string[];
|
||||
};
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type SpawnCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
) => Promise<SpawnResult>;
|
||||
type CatalogFetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
type CatalogLoader = () => Promise<unknown>;
|
||||
|
||||
const MAX_OUTPUT_CHARS = 12_000;
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
|
||||
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
|
||||
const MARKETPLACE_CATALOG_URL =
|
||||
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
const SECRET_PATTERN =
|
||||
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
|
||||
const SECRET_KEY_VALUE_PATTERN =
|
||||
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
|
||||
const SECRET_BEARER_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
|
||||
const SECRET_AUTHORIZATION_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
|
||||
|
||||
export async function fetchMarketplaceCatalog(
|
||||
fetchImpl: CatalogFetch = fetch,
|
||||
): Promise<unknown> {
|
||||
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
|
||||
return value === "mcp" || value === "skill" || value === "plugin";
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function readInstallInput(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput {
|
||||
const entry = readInstallRecord(args);
|
||||
const install =
|
||||
entry.install && typeof entry.install === "object"
|
||||
? (entry.install as Record<string, unknown>)
|
||||
: {};
|
||||
const installArgs = toStringArray(install.args);
|
||||
if (installArgs.length === 0) {
|
||||
throw new Error("marketplace install args are required");
|
||||
}
|
||||
const env = Array.isArray(install.env)
|
||||
? install.env
|
||||
.map((item): MarketplaceEnvVar | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
if (typeof candidate.name !== "string") return null;
|
||||
const parsed: MarketplaceEnvVar = {
|
||||
name: candidate.name,
|
||||
};
|
||||
if (typeof candidate.required === "boolean") {
|
||||
parsed.required = candidate.required;
|
||||
}
|
||||
if (typeof candidate.description === "string") {
|
||||
parsed.description = candidate.description;
|
||||
}
|
||||
if (typeof candidate.url === "string") {
|
||||
parsed.url = candidate.url;
|
||||
}
|
||||
return parsed;
|
||||
})
|
||||
.filter((item): item is MarketplaceEnvVar => item !== null)
|
||||
: undefined;
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
install: {
|
||||
args: installArgs,
|
||||
command:
|
||||
typeof install.command === "string" ? install.command : undefined,
|
||||
env,
|
||||
notes: typeof install.notes === "string" ? install.notes : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRecord(
|
||||
args?: Record<string, unknown>,
|
||||
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
|
||||
const entry =
|
||||
args?.entry && typeof args.entry === "object"
|
||||
? (args.entry as Record<string, unknown>)
|
||||
: (args ?? {});
|
||||
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
|
||||
throw new Error("marketplace entry id is required");
|
||||
}
|
||||
if (!isPrimitiveType(entry.type)) {
|
||||
throw new Error("marketplace entry type must be mcp, skill, or plugin");
|
||||
}
|
||||
return entry as Record<string, unknown> & {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRequest(args?: Record<string, unknown>) {
|
||||
const entry = readInstallRecord(args);
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
};
|
||||
}
|
||||
|
||||
function readLocalUninstallInput(args?: Record<string, unknown>): {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
name?: string;
|
||||
path?: string;
|
||||
} {
|
||||
const type = typeof args?.type === "string" ? args.type.trim() : "";
|
||||
if (
|
||||
type !== "mcp" &&
|
||||
type !== "skill" &&
|
||||
type !== "workflow" &&
|
||||
type !== "plugin"
|
||||
) {
|
||||
throw new Error(
|
||||
"local uninstall type must be mcp, skill, workflow, or plugin",
|
||||
);
|
||||
}
|
||||
const id =
|
||||
typeof args?.id === "string" && args.id.trim().length > 0
|
||||
? args.id.trim()
|
||||
: typeof args?.name === "string" && args.name.trim().length > 0
|
||||
? args.name.trim()
|
||||
: typeof args?.path === "string" && args.path.trim().length > 0
|
||||
? args.path.trim()
|
||||
: "";
|
||||
if (!id) {
|
||||
throw new Error("local uninstall id, name, or path is required");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: typeof args?.name === "string" ? args.name.trim() : undefined,
|
||||
path: typeof args?.path === "string" ? args.path.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallInputList(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput[] {
|
||||
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
|
||||
return rawEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
|
||||
const catalogEntries =
|
||||
catalog && typeof catalog === "object"
|
||||
? (catalog as Record<string, unknown>).entries
|
||||
: undefined;
|
||||
if (!Array.isArray(catalogEntries)) {
|
||||
throw new Error("marketplace catalog entries are required");
|
||||
}
|
||||
return catalogEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function marketplaceEntryKey(
|
||||
entry: Pick<MarketplaceInstallInput, "id" | "type">,
|
||||
) {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function redactOutput(value: string): string {
|
||||
const lines = value.split(/\r?\n/).map((line) => {
|
||||
if (!SECRET_PATTERN.test(line)) return line;
|
||||
return line
|
||||
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
|
||||
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
|
||||
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
|
||||
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
|
||||
.replace(
|
||||
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
});
|
||||
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
|
||||
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
|
||||
new Promise<SpawnResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
env: options.env ?? process.env,
|
||||
shell: options.shell ?? platform() === "win32",
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
|
||||
child.kill("SIGTERM");
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
forceKillTimeout.unref?.();
|
||||
timeout.unref?.();
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
const result = {
|
||||
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
function normalizeTransport(value: string | undefined): string {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertUrl(value: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
const [rawName, ...rest] = args;
|
||||
const name = rawName?.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP marketplace install requires a server name");
|
||||
}
|
||||
let transportType = "stdio";
|
||||
const headers: Record<string, string> = {};
|
||||
const targetArgs: string[] = [];
|
||||
let parsingMarketplaceOptions = true;
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
const arg = rest[index];
|
||||
if (parsingMarketplaceOptions && arg === "--") {
|
||||
targetArgs.push(...rest.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
|
||||
const next = rest[index + 1]?.trim();
|
||||
if (!next) throw new Error("--transport requires a value");
|
||||
transportType = normalizeTransport(next);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const shouldParseHeader =
|
||||
parsingMarketplaceOptions ||
|
||||
normalizeTransport(transportType) !== "stdio";
|
||||
if (
|
||||
shouldParseHeader &&
|
||||
(arg === "--header" || arg?.startsWith("--header="))
|
||||
) {
|
||||
const rawHeader =
|
||||
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
|
||||
if (!rawHeader) throw new Error("--header requires a value");
|
||||
const separatorIndex = rawHeader.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
const headerName = rawHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
|
||||
if (!headerName || !headerValue) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
headers[headerName] = headerValue;
|
||||
continue;
|
||||
}
|
||||
parsingMarketplaceOptions = false;
|
||||
targetArgs.push(arg);
|
||||
}
|
||||
transportType = normalizeTransport(transportType);
|
||||
if (transportType === "stdio") {
|
||||
if (Object.keys(headers).length > 0) {
|
||||
throw new Error("Stdio MCP installs do not support request headers.");
|
||||
}
|
||||
const [command, ...commandArgs] = targetArgs;
|
||||
if (!command?.trim()) {
|
||||
throw new Error("Stdio MCP install requires a command");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
command,
|
||||
args: commandArgs.length > 0 ? commandArgs : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error("Remote MCP install requires exactly one URL");
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertUrl(url);
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUserInstructionRemovalTarget(input: {
|
||||
type: "skill" | "workflow";
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): string {
|
||||
const filePath = resolve(input.path);
|
||||
const searchPaths =
|
||||
input.type === "skill"
|
||||
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
|
||||
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
|
||||
const containingRoot = searchPaths.find((root) =>
|
||||
isInsidePath(filePath, root),
|
||||
);
|
||||
if (!containingRoot) {
|
||||
throw new Error(
|
||||
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
|
||||
);
|
||||
}
|
||||
const stats = statSync(filePath, { throwIfNoEntry: false });
|
||||
if (!stats?.isFile()) {
|
||||
throw new Error(`${input.type} file does not exist: ${filePath}`);
|
||||
}
|
||||
if (input.type === "workflow") {
|
||||
return filePath;
|
||||
}
|
||||
const skillDir = dirname(filePath);
|
||||
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
|
||||
}
|
||||
|
||||
export async function uninstallLocalPrimitive(
|
||||
args?: Record<string, unknown>,
|
||||
options: { workspaceRoot?: string } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const input = readLocalUninstallInput(args);
|
||||
if (input.type === "mcp") {
|
||||
const name = input.name ?? input.id;
|
||||
const response = deleteMcpServer(name);
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${name}.`,
|
||||
details: { mcp: response },
|
||||
};
|
||||
}
|
||||
if (input.type === "plugin") {
|
||||
const result = await uninstallLocalPlugin({
|
||||
name: input.path ? undefined : (input.name ?? input.id),
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${result.name}.`,
|
||||
details: result as unknown as JsonRecord,
|
||||
};
|
||||
}
|
||||
if (input.type === "skill" || input.type === "workflow") {
|
||||
if (!input.path) {
|
||||
throw new Error(`${input.type} uninstall requires a path.`);
|
||||
}
|
||||
const target = resolveUserInstructionRemovalTarget({
|
||||
type: input.type,
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const stats = statSync(target, { throwIfNoEntry: false });
|
||||
if (!stats) {
|
||||
throw new Error(`${input.type} target does not exist: ${target}`);
|
||||
}
|
||||
rmSync(target, { recursive: stats.isDirectory(), force: true });
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${input.name ?? basename(target)}.`,
|
||||
details: { path: target },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported local uninstall type: ${input.type}`);
|
||||
}
|
||||
|
||||
function hashSource(source: string): string {
|
||||
return createHash("sha256").update(source).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replace(/^@/, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return sanitized || "plugin";
|
||||
}
|
||||
|
||||
function sanitizeSkillSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._]+/g, "-")
|
||||
.replace(/^[.-]+|[.-]+$/g, "")
|
||||
.slice(0, 255);
|
||||
return sanitized || "skill";
|
||||
}
|
||||
|
||||
function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
const slug = source.trim();
|
||||
if (!isOfficialPluginSlug(slug)) return undefined;
|
||||
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
|
||||
return join(
|
||||
resolveClineDir(),
|
||||
"plugins",
|
||||
"_installed",
|
||||
"official",
|
||||
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "plugin") return false;
|
||||
const [source] = entry.install.args ?? [];
|
||||
if (!source) return false;
|
||||
const installPath = getOfficialPluginInstallPath(source);
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return (
|
||||
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string | undefined): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const addCandidate = (value: string | undefined) => {
|
||||
const normalized = sanitizeSkillSegment(value ?? "");
|
||||
if (normalized && normalized !== "skill") {
|
||||
candidates.add(normalized);
|
||||
}
|
||||
};
|
||||
addCandidate(entry.id);
|
||||
addCandidate(entry.name);
|
||||
const installArgs = entry.install.args ?? [];
|
||||
for (let index = 0; index < installArgs.length; index++) {
|
||||
const arg = installArgs[index];
|
||||
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
|
||||
addCandidate(installArgs[index + 1]);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const skillFilter = arg.split("@").at(1);
|
||||
if (skillFilter) {
|
||||
addCandidate(skillFilter);
|
||||
}
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function getGlobalSkillPaths(skillName: string): string[] {
|
||||
return [
|
||||
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
|
||||
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
|
||||
].filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function ensureGlobalSkillsDirWritable(): void {
|
||||
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
|
||||
try {
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
const probePath = join(
|
||||
skillsDir,
|
||||
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
writeFileSync(probePath, "", { flag: "wx" });
|
||||
unlinkSync(probePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return findInstalledGlobalSkillName(entry) !== undefined;
|
||||
}
|
||||
|
||||
function findInstalledGlobalSkillName(
|
||||
entry: MarketplaceInstallInput,
|
||||
): string | undefined {
|
||||
if (entry.type !== "skill") return undefined;
|
||||
const candidates = getSkillInstallCandidates(entry);
|
||||
return candidates.find((candidate) =>
|
||||
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasMatchingInventoryItem(
|
||||
items: unknown,
|
||||
entry: MarketplaceInstallInput,
|
||||
): boolean {
|
||||
if (!Array.isArray(items)) return false;
|
||||
const candidates = new Set([
|
||||
normalizeMatchValue(entry.id),
|
||||
normalizeMatchValue(entry.name),
|
||||
...(entry.install.args ?? []).map(normalizeMatchValue),
|
||||
]);
|
||||
candidates.delete("");
|
||||
return items.some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as JsonRecord;
|
||||
const values = [
|
||||
typeof record.name === "string" ? record.name : undefined,
|
||||
typeof record.id === "string" ? record.id : undefined,
|
||||
typeof record.path === "string" ? record.path : undefined,
|
||||
]
|
||||
.map(normalizeMatchValue)
|
||||
.filter(Boolean);
|
||||
return values.some((value) => candidates.has(value));
|
||||
});
|
||||
}
|
||||
|
||||
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "mcp") return false;
|
||||
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const response = readMcpServersResponse();
|
||||
const servers = Array.isArray(response.servers) ? response.servers : [];
|
||||
return servers.some((server) => {
|
||||
if (!server || typeof server !== "object") return false;
|
||||
const record = server as JsonRecord;
|
||||
return record.name === input.name;
|
||||
});
|
||||
}
|
||||
|
||||
function isMarketplaceEntryInstalled(
|
||||
entry: MarketplaceInstallInput,
|
||||
inventory?: JsonRecord,
|
||||
): boolean {
|
||||
try {
|
||||
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
|
||||
if (entry.type === "plugin") {
|
||||
return (
|
||||
isOfficialPluginInstalled(entry) ||
|
||||
hasMatchingInventoryItem(inventory?.plugins, entry)
|
||||
);
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return isGlobalSkillInstalled(entry);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutput(result: SpawnResult): string | undefined {
|
||||
const output = redactOutput(
|
||||
[result.stdout, result.stderr].filter(Boolean).join("\n"),
|
||||
);
|
||||
return output.trim().length > 0 ? output.trim() : undefined;
|
||||
}
|
||||
|
||||
async function installSkill(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
if (isGlobalSkillInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
ensureGlobalSkillsDirWritable();
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
...(entry.install.args ?? []),
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
const output = commandOutput(result);
|
||||
if (/\bFailed to install\b/i.test(output ?? "")) {
|
||||
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
|
||||
}
|
||||
if (!isGlobalSkillInstalled(entry)) {
|
||||
throw new Error(
|
||||
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Plugin marketplace installs currently support exactly one source argument.",
|
||||
);
|
||||
}
|
||||
if (isOfficialPluginInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry, spawnCommand);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
let mcpDetails: JsonRecord | undefined;
|
||||
const result = await uninstallCoreMarketplaceEntry(
|
||||
entry satisfies MarketplaceEntryInput,
|
||||
{
|
||||
deleteMcpServer: (name) => {
|
||||
mcpDetails = deleteMcpServer(name);
|
||||
},
|
||||
spawnCommand: (command, commandArgs) =>
|
||||
spawnCommand(command, commandArgs),
|
||||
},
|
||||
);
|
||||
return {
|
||||
...(result satisfies MarketplaceActionResult),
|
||||
details: mcpDetails ? { mcp: mcpDetails } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return installMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return uninstallMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export function listMarketplaceInstalledEntries(
|
||||
args?: Record<string, unknown>,
|
||||
inventory?: JsonRecord,
|
||||
): MarketplaceInstallStatusResult {
|
||||
const entries = readInstallInputList(args);
|
||||
const installedKeys = entries
|
||||
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
|
||||
.map(marketplaceEntryKey);
|
||||
return { installedKeys };
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return installMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return uninstallMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
export function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function setMcpServerDisabled(
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
const name = String(input.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const previousName = String(
|
||||
input.previousName ?? input.previous_name ?? "",
|
||||
).trim();
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
}
|
||||
: {
|
||||
transport: {
|
||||
type: transportType === "sse" ? "sse" : "streamableHttp",
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
};
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function deleteMcpServer(name: string): JsonRecord {
|
||||
if (!name) throw new Error("server name is required");
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFetchHandler } from "./server";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
function createTestServer() {
|
||||
return {
|
||||
port: 3126,
|
||||
upgrade: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function createHandler(onShutdown = vi.fn()) {
|
||||
return createFetchHandler({} as SidecarContext, onShutdown);
|
||||
}
|
||||
|
||||
describe("sidecar HTTP origin checks", () => {
|
||||
it("rejects cross-origin shutdown preflight requests", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/shutdown", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
"access-control-request-method": "POST",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(response?.headers.get("access-control-allow-origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects cross-origin shutdown POST requests", async () => {
|
||||
const onShutdown = vi.fn();
|
||||
const server = createTestServer();
|
||||
const response = await createHandler(onShutdown)(
|
||||
new Request("http://127.0.0.1:3126/shutdown", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(onShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-origin websocket upgrades", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/transport", {
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
expect(server.upgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows desktop webview origins in preflight responses", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/api/marketplace/catalog", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(204);
|
||||
expect(response?.headers.get("access-control-allow-origin")).toBe(
|
||||
"tauri://localhost",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_MODE,
|
||||
@@ -14,6 +15,49 @@ type SidecarServer = {
|
||||
upgrade(req: Request): boolean;
|
||||
};
|
||||
|
||||
const TRUSTED_BROWSER_ORIGINS = new Set([
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://localhost:3125",
|
||||
"http://127.0.0.1:3125",
|
||||
]);
|
||||
|
||||
const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
function readOrigin(req: Request): string | undefined {
|
||||
const origin = req.headers.get("origin")?.trim();
|
||||
return origin ? origin : undefined;
|
||||
}
|
||||
|
||||
function isTrustedRequestOrigin(req: Request): boolean {
|
||||
const origin = readOrigin(req);
|
||||
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
|
||||
}
|
||||
|
||||
function corsHeaders(req: Request): Record<string, string> {
|
||||
const origin = readOrigin(req);
|
||||
return {
|
||||
"access-control-allow-headers": "accept, content-type",
|
||||
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
|
||||
? {
|
||||
"access-control-allow-origin": origin,
|
||||
vary: "Origin",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonHeaders(req: Request): Record<string, string> {
|
||||
return {
|
||||
...JSON_HEADERS,
|
||||
...corsHeaders(req),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -27,6 +71,29 @@ function jsonResponse(
|
||||
return JSON.stringify({ type: "response", id, ok, result, error });
|
||||
}
|
||||
|
||||
function createJsonResponse(
|
||||
req: Request,
|
||||
body: unknown,
|
||||
status = 200,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
|
||||
const EMPTY_MARKETPLACE_CATALOG = {
|
||||
version: 1,
|
||||
counts: {
|
||||
total: 0,
|
||||
plugins: 0,
|
||||
skills: 0,
|
||||
mcps: 0,
|
||||
},
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bun HTTP + WebSocket server
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,13 +133,20 @@ export function startServer(
|
||||
return { port: server.port };
|
||||
}
|
||||
|
||||
function createFetchHandler(
|
||||
export function createFetchHandler(
|
||||
_ctx: SidecarContext,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
) {
|
||||
return async (req: Request, server: SidecarServer) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return new Response(null, { status: 204, headers: corsHeaders(req) });
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -80,15 +154,39 @@ function createFetchHandler(
|
||||
mode: SIDECAR_MODE,
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
{ headers: jsonHeaders(req) },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/transport" && server.upgrade(req)) {
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
server.upgrade(req)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(req, await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(req, {
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/shutdown" && req.method === "POST") {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 403,
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void onShutdown?.("code_sidecar_shutdown_endpoint")
|
||||
.catch((error) => {
|
||||
@@ -101,7 +199,7 @@ function createFetchHandler(
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AgentToolContext,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -103,6 +104,7 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Bun/JavaScriptCore requires JIT + shared executable memory under the hardened runtime -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -179,30 +179,37 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn desktop_backend_binary_name() -> String {
|
||||
fn desktop_backend_binary_names() -> Vec<String> {
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
let bundled_name = format!("code-sidecar{extension}");
|
||||
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
|
||||
if target_triple.is_empty() {
|
||||
return "code-sidecar".to_string();
|
||||
return vec![bundled_name];
|
||||
}
|
||||
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
format!("code-sidecar-{target_triple}{extension}")
|
||||
vec![
|
||||
bundled_name,
|
||||
format!("code-sidecar-{target_triple}{extension}"),
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
|
||||
if cfg!(debug_assertions) {
|
||||
return None;
|
||||
}
|
||||
let binary_name = desktop_backend_binary_name();
|
||||
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
let candidates = [
|
||||
explicit,
|
||||
Some(
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = explicit {
|
||||
candidates.push(path);
|
||||
}
|
||||
|
||||
for binary_name in desktop_backend_binary_names() {
|
||||
candidates.push(
|
||||
PathBuf::from(&context.workspace_root)
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
@@ -210,17 +217,23 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
.join(&binary_name),
|
||||
),
|
||||
current_exe
|
||||
);
|
||||
if let Some(path) = current_exe
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name))),
|
||||
current_exe.as_ref().and_then(|path| {
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
|
||||
{
|
||||
candidates.push(path);
|
||||
}
|
||||
if let Some(path) = current_exe.as_ref().and_then(|path| {
|
||||
path.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(|parent| parent.join("Resources").join(&binary_name))
|
||||
}),
|
||||
];
|
||||
candidates.into_iter().flatten().find(|path| path.exists())
|
||||
}) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -33,6 +33,10 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const MARKETPLACE_CATALOG_URL =
|
||||
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
const EMPTY_MARKETPLACE_CATALOG = {
|
||||
version: 1,
|
||||
counts: {
|
||||
total: 0,
|
||||
plugins: 0,
|
||||
skills: 0,
|
||||
mcps: 0,
|
||||
},
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(MARKETPLACE_CATALOG_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return Response.json({
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
|
||||
});
|
||||
}
|
||||
return Response.json(await response.json());
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,39 +9,73 @@
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
--background: oklch(0.13 0.005 260);
|
||||
--foreground: oklch(0.93 0 0);
|
||||
--card: oklch(0.16 0.005 260);
|
||||
--card-foreground: oklch(0.93 0 0);
|
||||
--popover: oklch(0.16 0.005 260);
|
||||
--popover-foreground: oklch(0.93 0 0);
|
||||
--primary: oklch(0.75 0.12 165);
|
||||
--primary-foreground: oklch(0.13 0.005 260);
|
||||
--secondary: oklch(0.22 0.005 260);
|
||||
--secondary-foreground: oklch(0.85 0 0);
|
||||
--muted: oklch(0.2 0.005 260);
|
||||
--muted-foreground: oklch(0.55 0 0);
|
||||
--accent: oklch(0.22 0.01 260);
|
||||
--accent-foreground: oklch(0.93 0 0);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--destructive-foreground: oklch(0.93 0 0);
|
||||
--border: oklch(0.25 0.005 260);
|
||||
--input: oklch(0.2 0.005 260);
|
||||
--ring: oklch(0.75 0.12 165);
|
||||
--chart-1: oklch(0.75 0.12 165);
|
||||
--chart-2: oklch(0.65 0.15 250);
|
||||
--chart-3: oklch(0.7 0.15 50);
|
||||
--chart-4: oklch(0.65 0.18 320);
|
||||
--chart-5: oklch(0.6 0.12 200);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.11 0.005 260);
|
||||
--sidebar-foreground: oklch(0.85 0 0);
|
||||
--sidebar-primary: oklch(0.75 0.12 165);
|
||||
--sidebar-primary-foreground: oklch(0.13 0.005 260);
|
||||
--sidebar-accent: oklch(0.18 0.008 260);
|
||||
--sidebar-accent-foreground: oklch(0.93 0 0);
|
||||
--sidebar-border: oklch(0.22 0.005 260);
|
||||
--sidebar-ring: oklch(0.75 0.12 165);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -104,8 +138,17 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
.markdown * {
|
||||
@apply text-sm;
|
||||
@apply text-sm leading-relaxed;
|
||||
}
|
||||
.markdown + .markdown {
|
||||
@apply mt-2;
|
||||
}
|
||||
.markdown p {
|
||||
@apply my-2 first:mt-0 last:mb-0;
|
||||
}
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
|
||||
@@ -22,17 +22,21 @@ import {
|
||||
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
import { SettingsView } from "@/components/views/settings/settings-view";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
import { useSessionHistory } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
getSessionMetadataTitle,
|
||||
type SessionHistoryItem,
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
|
||||
function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
@@ -70,17 +74,24 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<"chat" | "diff" | "settings">("chat");
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [threads, setThreads] = useState<Thread[]>(() => [
|
||||
{ id: makeThreadId() },
|
||||
]);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string>(
|
||||
() => threads[0]?.id,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncHubTheme();
|
||||
return watchSystemHubTheme();
|
||||
}, []);
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
const id = makeThreadId();
|
||||
setThreads((prev) => [...prev, { id }]);
|
||||
setActiveThreadId(id);
|
||||
setView("chat");
|
||||
}, []);
|
||||
|
||||
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
|
||||
@@ -98,6 +109,7 @@ export default function Home() {
|
||||
return [...prev, { id: threadId, historySession: session }];
|
||||
});
|
||||
setActiveThreadId(threadId);
|
||||
setView("chat");
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
@@ -176,6 +188,12 @@ export default function Home() {
|
||||
?.sessionId ?? null;
|
||||
const activeThread =
|
||||
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
|
||||
const sessionHistory = useSessionHistory({
|
||||
activeSessionId: activeHistorySessionId,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
onOpenSession: handleOpenSession,
|
||||
onUpdateSessionMetadata: handleUpdateSessionMetadata,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -188,13 +206,18 @@ export default function Home() {
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{activeThread ? (
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
@@ -241,6 +264,7 @@ function ChatThreadPane({
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState,
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
config,
|
||||
@@ -454,7 +478,12 @@ function ChatThreadPane({
|
||||
async (preferredWorkspace?: string) => {
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces(results);
|
||||
setWorkspaces((current) =>
|
||||
current.length === results.length &&
|
||||
current.every((workspace, index) => workspace === results[index])
|
||||
? current
|
||||
: results,
|
||||
);
|
||||
} finally {
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
@@ -595,6 +624,26 @@ function ChatThreadPane({
|
||||
await sendPrompt(trimmed, toSend);
|
||||
}, [pendingAttachments, promptInput, sendPrompt]);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
|
||||
setConfig((prev) => {
|
||||
if (
|
||||
prev.thinking === next.thinking &&
|
||||
prev.reasoningEffort === next.reasoningEffort
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
thinking: next.thinking,
|
||||
reasoningEffort:
|
||||
next.thinking === false ? undefined : next.reasoningEffort,
|
||||
};
|
||||
});
|
||||
},
|
||||
[setConfig],
|
||||
);
|
||||
|
||||
const handleUndoQueuedPrompt = useCallback(
|
||||
async (item: PromptInQueue) => {
|
||||
const removed = await removePromptInQueue(item.id);
|
||||
@@ -827,9 +876,7 @@ function ChatThreadPane({
|
||||
workspaceRoot: resolvedWorkspaceRoot,
|
||||
workspaces,
|
||||
listWorkspaces,
|
||||
refreshWorkspaces: async () => {
|
||||
await refreshWorkspaces();
|
||||
},
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
}),
|
||||
@@ -851,8 +898,17 @@ function ChatThreadPane({
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center gap-3 bg-background text-foreground">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{chatTransportState !== "connected" ? "Connecting..." : "Loading..."}
|
||||
{chatTransportState === "unavailable"
|
||||
? "Desktop backend unavailable"
|
||||
: chatTransportState !== "connected"
|
||||
? "Connecting..."
|
||||
: "Loading..."}
|
||||
</p>
|
||||
{chatTransportError ? (
|
||||
<p className="max-w-xl px-6 text-center text-xs text-muted-foreground">
|
||||
{chatTransportError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -959,6 +1015,7 @@ function ChatThreadPane({
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
@@ -996,8 +1053,10 @@ function ChatThreadPane({
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
reasoningEffort={config.reasoningEffort}
|
||||
status={status}
|
||||
summary={summary}
|
||||
thinking={config.thinking}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function AgentHeader({
|
||||
const triggerDeleteSession = () => onDeleteSession?.();
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-card px-4">
|
||||
<header className="flex h-12 items-center justify-between px-4">
|
||||
{/* Left: thread title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -179,7 +179,7 @@ export function AgentHeader({
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-primary">+{additions}</span>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
{/* New Chat Button */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer cursor-pointer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer cursor-pointer data-[state=checked]:bg-primary/20 data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "@/components/ui/combobox";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
@@ -66,19 +66,62 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-2.5-pro"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
};
|
||||
|
||||
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-2.5-pro"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
};
|
||||
|
||||
type ReasoningEffort = NonNullable<ChatSessionConfig["reasoningEffort"]>;
|
||||
type ReasoningEffortOption = {
|
||||
label: string;
|
||||
value: "none" | ReasoningEffort;
|
||||
};
|
||||
|
||||
const DEFAULT_REASONING_EFFORT: ReasoningEffortOption = {
|
||||
label: "Low",
|
||||
value: "low",
|
||||
};
|
||||
|
||||
const EFFORT_LEVELS: ReasoningEffortOption[] = [
|
||||
{ label: "None", value: "none" },
|
||||
DEFAULT_REASONING_EFFORT,
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Extra", value: "xhigh" },
|
||||
];
|
||||
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
|
||||
const PROMPT_INPUT_FOCUSED_ROWS = 5;
|
||||
|
||||
function resolveEffortIndex(
|
||||
thinking: ChatSessionConfig["thinking"],
|
||||
reasoningEffort: ChatSessionConfig["reasoningEffort"],
|
||||
): number {
|
||||
if (thinking === false) {
|
||||
return 0;
|
||||
}
|
||||
const index = EFFORT_LEVELS.findIndex(
|
||||
(option) => option.value === reasoningEffort,
|
||||
);
|
||||
return index >= 0 ? index : 1;
|
||||
}
|
||||
|
||||
function buildReasoningConfig(
|
||||
option: ReasoningEffortOption,
|
||||
): Pick<ChatSessionConfig, "thinking" | "reasoningEffort"> {
|
||||
if (option.value === "none") {
|
||||
return { thinking: false, reasoningEffort: undefined };
|
||||
}
|
||||
return { thinking: true, reasoningEffort: option.value };
|
||||
}
|
||||
|
||||
function hasReasoningCapability(
|
||||
providerReasoningModels: Record<string, string[]>,
|
||||
provider: string,
|
||||
@@ -149,12 +192,17 @@ type ChatInputBarProps = {
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: "act" | "plan";
|
||||
thinking: ChatSessionConfig["thinking"];
|
||||
reasoningEffort: ChatSessionConfig["reasoningEffort"];
|
||||
gitBranch: string;
|
||||
promptInput: string;
|
||||
onPromptInputChange: (value: string) => void;
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModeToggle: () => void;
|
||||
onReasoningChange: (
|
||||
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
|
||||
) => void;
|
||||
onRefreshGitBranch: () => void;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
@@ -183,12 +231,15 @@ export function ChatInputBar({
|
||||
provider,
|
||||
model,
|
||||
mode,
|
||||
thinking,
|
||||
reasoningEffort,
|
||||
gitBranch,
|
||||
promptInput,
|
||||
onPromptInputChange,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModeToggle,
|
||||
onReasoningChange,
|
||||
onRefreshGitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
@@ -219,10 +270,9 @@ export function ChatInputBar({
|
||||
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
|
||||
);
|
||||
const canSend = hasDraft;
|
||||
const effortLevels = ["Low", "Medium", "High"] as const;
|
||||
const [effortIndex, setEffortIndex] = useState(1);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [promptInputFocused, setPromptInputFocused] = useState(false);
|
||||
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
|
||||
@@ -258,13 +308,35 @@ export function ChatInputBar({
|
||||
}
|
||||
return `${total.toLocaleString()} tokens`;
|
||||
}, [summary.tokensIn, summary.tokensOut]);
|
||||
const effortLabel = effortLevels[effortIndex];
|
||||
const effortIndex = useMemo(
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
[reasoningEffort, thinking],
|
||||
);
|
||||
const effortLabel = modelSupportsReasoning
|
||||
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
|
||||
: "None";
|
||||
const handleEffortCycle = useCallback(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
return;
|
||||
}
|
||||
setEffortIndex((current) => (current + 1) % effortLevels.length);
|
||||
}, [effortLevels.length, modelSupportsReasoning]);
|
||||
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
|
||||
if (!nextOption) {
|
||||
return;
|
||||
}
|
||||
onReasoningChange(buildReasoningConfig(nextOption));
|
||||
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
if (thinking !== false || reasoningEffort !== undefined) {
|
||||
onReasoningChange({ thinking: false, reasoningEffort: undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (thinking === undefined && reasoningEffort === undefined) {
|
||||
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
|
||||
}
|
||||
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
|
||||
|
||||
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
|
||||
setEditingQueuedPromptId(item.id);
|
||||
@@ -330,20 +402,6 @@ export function ChatInputBar({
|
||||
}
|
||||
}, [cancelQueuedPromptEdit, editingQueuedPromptId, promptsInQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.style.height = "0px";
|
||||
const styles = window.getComputedStyle(input);
|
||||
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
|
||||
const maxHeight = lineHeight * 10;
|
||||
const nextHeight = Math.min(input.scrollHeight, maxHeight);
|
||||
input.style.height = `${nextHeight}px`;
|
||||
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const nextMention = getActiveMention(promptInput, cursorIndex);
|
||||
setActiveMention(nextMention);
|
||||
@@ -748,7 +806,7 @@ export function ChatInputBar({
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
|
||||
<textarea
|
||||
className="max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onChange={(e) => {
|
||||
onPromptInputChange(e.target.value);
|
||||
setCursorIndex(
|
||||
@@ -760,6 +818,8 @@ export function ChatInputBar({
|
||||
e.currentTarget.selectionStart ?? promptInput.length,
|
||||
)
|
||||
}
|
||||
onBlur={() => setPromptInputFocused(false)}
|
||||
onFocus={() => setPromptInputFocused(true)}
|
||||
onKeyDown={(e) => {
|
||||
// Slash command menu takes priority when open.
|
||||
if (slashOpen && filteredSlashCommands.length > 0) {
|
||||
@@ -835,10 +895,14 @@ export function ChatInputBar({
|
||||
placeholder={
|
||||
isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for workflow or @ to attach files"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
}
|
||||
ref={promptInputRef}
|
||||
rows={1}
|
||||
rows={
|
||||
promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
}
|
||||
value={promptInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,22 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
FileIcon,
|
||||
FileSearch,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
SplitIcon,
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { parseApplyPatchInput } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../ui/markdown";
|
||||
import { normalizeTitle } from "../../utils";
|
||||
@@ -36,7 +40,11 @@ import { WelcomeScreen } from "./welcome-chat";
|
||||
type ChatMessagesProps = {
|
||||
sessionId: string | null;
|
||||
status: ChatSessionStatus;
|
||||
chatTransportState?: "connecting" | "reconnecting" | "connected";
|
||||
chatTransportState?:
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
isSessionSwitching?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -365,10 +373,10 @@ function ChatMessagesImpl({
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
className="h-full min-h-0 min-w-0 overflow-y-auto"
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full px-6 py-6">
|
||||
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
{showIdleDetails ? (
|
||||
<WelcomeScreen
|
||||
provider={provider}
|
||||
@@ -377,7 +385,7 @@ function ChatMessagesImpl({
|
||||
quickActions={[]}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full h-full">
|
||||
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
@@ -474,7 +482,9 @@ function ChatMessagesImpl({
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{chatTransportState === "reconnecting"
|
||||
? "Reconnecting chat..."
|
||||
: "Connecting chat..."}
|
||||
: chatTransportState === "unavailable"
|
||||
? "Chat backend unavailable"
|
||||
: "Connecting chat..."}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
@@ -569,7 +579,7 @@ function ToolApprovalPanel({
|
||||
Request {item.requestId}
|
||||
{item.iteration != null ? ` · Iteration ${item.iteration}` : ""}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
|
||||
<pre className="mt-2 max-h-44 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
|
||||
{formatApprovalInput(item.input)}
|
||||
</pre>
|
||||
{error ? (
|
||||
@@ -722,6 +732,17 @@ function MessageBubble({
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
const checkpoint = message.meta?.checkpoint;
|
||||
const shouldRenderAssistantActions =
|
||||
message.role === "assistant" &&
|
||||
!isStreaming &&
|
||||
!isError &&
|
||||
Boolean(onCopyRawText || onForkSession);
|
||||
const shouldRenderUserActions =
|
||||
isUser && Boolean(onCopyRawText || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
const hiddenActionButtonsClassName =
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
|
||||
|
||||
if (message.role === "tool") {
|
||||
return <ToolMessageBlock message={message} />;
|
||||
@@ -732,71 +753,106 @@ function MessageBubble({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex", isUser ? "justify-end" : "justify-start w-full")}
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 pl-3 text-sm",
|
||||
isUser && "bg-card text-foreground/80 max-w-[50%]",
|
||||
!isUser && !isError && "text-foreground w-full",
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[50%] flex-col items-end gap-1",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
{isStreaming && message.role === "assistant" ? (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap">
|
||||
{normalizedContent || " "}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<MemoizedMarkdown
|
||||
content={normalizedContent || " "}
|
||||
id={message.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isUser && checkpoint ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{wasCopied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{isStreaming && message.role === "assistant" ? (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{normalizedContent || " "}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={normalizedContent || " "}
|
||||
id={message.id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{shouldRenderUserActions ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-6 items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-2",
|
||||
keepUserActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
Undo
|
||||
</Button>
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied ? "Copied user message" : "Copy user message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label="Restore checkpoint"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
title="Restore checkpoint"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
@@ -805,30 +861,61 @@ function MessageBubble({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!isUser &&
|
||||
!isError &&
|
||||
!isStreaming &&
|
||||
message.role === "assistant" &&
|
||||
onForkSession ? (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session — copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
</Button>
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Fork session"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session - copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">
|
||||
{forkError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -850,17 +937,18 @@ function ReasoningBlock({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="my-2">
|
||||
<Button
|
||||
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
Thinking
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="mt-1 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{displayContent}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -878,6 +966,10 @@ type ToolPayload = {
|
||||
type ToolSummary = {
|
||||
label: string;
|
||||
details: string[];
|
||||
diff?: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
};
|
||||
|
||||
function pruneRequestMap<T extends string>(
|
||||
@@ -966,7 +1058,12 @@ function classifyTool(
|
||||
].includes(normalized)
|
||||
)
|
||||
return "exploration";
|
||||
if (["editor", "edit_file", "edit"].includes(normalized)) return "file-edit";
|
||||
if (
|
||||
["editor", "edit_file", "edit", "apply_patch", "apply-patch"].includes(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return "file-edit";
|
||||
if (["bash", "run_commands"].includes(normalized)) return "bash";
|
||||
if (["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized))
|
||||
return "spawn";
|
||||
@@ -985,6 +1082,62 @@ function asStringArray(value: unknown): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* read_files accepts many input shapes: { files: [{ path }] }, { files: path },
|
||||
* { file_paths: [...] }, { paths: [...] }, a bare request, an array, or a string.
|
||||
*/
|
||||
function extractReadFilePaths(input: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
out.push(value);
|
||||
return;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (record && typeof record.path === "string" && record.path.length > 0) {
|
||||
out.push(record.path);
|
||||
}
|
||||
};
|
||||
const record = asRecord(input);
|
||||
const candidates =
|
||||
record?.files ?? record?.file_paths ?? record?.paths ?? record ?? input;
|
||||
if (Array.isArray(candidates)) {
|
||||
for (const candidate of candidates) {
|
||||
push(candidate);
|
||||
}
|
||||
} else {
|
||||
push(candidates);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* run_commands entries can be shell strings or structured { command, args }.
|
||||
*/
|
||||
function extractCommands(input: unknown): string[] {
|
||||
const inputObject = asRecord(input);
|
||||
const raw = Array.isArray(inputObject?.commands)
|
||||
? inputObject.commands
|
||||
: typeof inputObject?.command === "string"
|
||||
? [inputObject.command]
|
||||
: typeof input === "string"
|
||||
? [input]
|
||||
: [];
|
||||
const out: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) {
|
||||
out.push(entry);
|
||||
continue;
|
||||
}
|
||||
const record = asRecord(entry);
|
||||
if (record && typeof record.command === "string") {
|
||||
const args = asStringArray(record.args);
|
||||
out.push([record.command, ...args].join(" "));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toDisplayPath(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts.at(-1) || path;
|
||||
@@ -1025,10 +1178,10 @@ function buildToolSummary(
|
||||
const inputObject = asRecord(input);
|
||||
|
||||
if (["read_files", "file_read", "file-read"].includes(normalized)) {
|
||||
const files = asStringArray(inputObject?.file_paths);
|
||||
const files = extractReadFilePaths(input);
|
||||
if (files.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(files.length, "file")}`,
|
||||
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
|
||||
details: files.map(
|
||||
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
|
||||
),
|
||||
@@ -1047,14 +1200,8 @@ function buildToolSummary(
|
||||
}
|
||||
|
||||
if (["run_commands", "bash"].includes(normalized)) {
|
||||
const commands = asStringArray(inputObject?.commands);
|
||||
if (commands.length === 1) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${commands[0]}`,
|
||||
details: [commands[0]],
|
||||
};
|
||||
}
|
||||
if (commands.length > 1) {
|
||||
const commands = extractCommands(input);
|
||||
if (commands.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
|
||||
details: commands.map((command) => command.trim()),
|
||||
@@ -1084,9 +1231,44 @@ function buildToolSummary(
|
||||
}
|
||||
}
|
||||
|
||||
if (["apply_patch", "apply-patch"].includes(normalized)) {
|
||||
const patchText =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: typeof inputObject?.input === "string"
|
||||
? inputObject.input
|
||||
: "";
|
||||
const fileDiffs = patchText ? parseApplyPatchInput(patchText) : [];
|
||||
if (fileDiffs.length > 0) {
|
||||
const additions = fileDiffs.reduce((sum, d) => sum + d.additions, 0);
|
||||
const deletions = fileDiffs.reduce((sum, d) => sum + d.deletions, 0);
|
||||
return {
|
||||
label: `${inProgress ? "Editing" : "Edited"} ${pluralize(fileDiffs.length, "file")}`,
|
||||
diff: { additions, deletions },
|
||||
details: fileDiffs.map(
|
||||
(d) =>
|
||||
`${inProgress ? "Editing" : "Edited"} ${toDisplayPath(d.path)} +${d.additions} -${d.deletions}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: inProgress ? "Applying patch" : "Applied patch",
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (["editor", "edit_file", "edit"].includes(normalized)) {
|
||||
// Current editor schema has no `command`; derive it from the input shape.
|
||||
const command =
|
||||
typeof inputObject?.command === "string" ? inputObject.command : "edit";
|
||||
typeof inputObject?.command === "string"
|
||||
? inputObject.command
|
||||
: inputObject?.insert_line != null
|
||||
? "insert"
|
||||
: typeof inputObject?.old_text === "string"
|
||||
? "str_replace"
|
||||
: typeof inputObject?.new_text === "string"
|
||||
? "create"
|
||||
: "edit";
|
||||
const path =
|
||||
typeof inputObject?.path === "string"
|
||||
? toDisplayPath(inputObject.path)
|
||||
@@ -1107,14 +1289,12 @@ function buildToolSummary(
|
||||
: command === "insert"
|
||||
? "Inserted"
|
||||
: "Edited";
|
||||
// The label already carries all the information; no expandable details.
|
||||
const detail = `${action} ${path}`;
|
||||
if (diff) {
|
||||
return {
|
||||
label: `${detail} +${diff.additions} -${diff.deletions}`,
|
||||
details: [detail],
|
||||
};
|
||||
return { label: detail, diff, details: [] };
|
||||
}
|
||||
return { label: detail, details: [detail] };
|
||||
return { label: detail, details: [] };
|
||||
}
|
||||
|
||||
const query =
|
||||
@@ -1162,13 +1342,17 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
hookEventName === "history_tool_use" ||
|
||||
(Boolean(payload) && payload?.result == null && !payload?.isError);
|
||||
const kind = classifyTool(toolName);
|
||||
const Icon =
|
||||
kind === "exploration"
|
||||
const isFileRead = ["read_files", "file_read", "file-read"].includes(
|
||||
toolName.toLowerCase(),
|
||||
);
|
||||
const Icon = isFileRead
|
||||
? FileIcon
|
||||
: kind === "exploration"
|
||||
? Search
|
||||
: kind === "file-edit"
|
||||
? FileEdit
|
||||
: kind === "bash"
|
||||
? Terminal
|
||||
? SquareTerminalIcon
|
||||
: kind === "spawn"
|
||||
? Bot
|
||||
: FileSearch;
|
||||
@@ -1180,39 +1364,52 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 1 || Boolean(inputPreview || resultPreview);
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start w-full">
|
||||
<div className={cn("w-full rounded-xl text-xs")}>
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
<Button
|
||||
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-3 text-destructive/80" />
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-3" />
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span>{summary.label}</span>
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">
|
||||
-{summary.diff.deletions}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{hasExpandedSections ? (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3" />
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="pl-8 text-muted-foreground">
|
||||
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div className="text-xxs" key={`${message.id}_${detail}`}>
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
@@ -1223,7 +1420,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -1235,7 +1432,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<pre className="max-h-64 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{resultPreview}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -108,10 +108,10 @@ export function WelcomeScreen({
|
||||
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
What would you like to build?
|
||||
What can I do for you?
|
||||
</h1>
|
||||
<p className="mt-2 text-balance text-center text-muted-foreground">
|
||||
Start a conversation to explore, edit, and ship code together.
|
||||
Let's explore, edit, and ship code together!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageFrameProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function PageFrame({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
type PageHeaderProps = {
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
meta?: ReactNode;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
className,
|
||||
description,
|
||||
icon: Icon,
|
||||
meta,
|
||||
title,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
|
||||
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type PageEmptyStateProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandBadgeProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CommandBadge({ children, className }: CommandBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Filter,
|
||||
Folder,
|
||||
GitFork,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
basenamePath,
|
||||
formatCostUsd,
|
||||
formatRelativeTime,
|
||||
parseTimestamp,
|
||||
type SessionThread,
|
||||
type UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import type { SessionHistoryItem } from "@/lib/session-history";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SessionsViewProps = {
|
||||
activeSessionId?: string | null;
|
||||
history: UseSessionHistoryResult;
|
||||
};
|
||||
|
||||
function statusTone(status?: string): string {
|
||||
if (status === "running") return "bg-green-500";
|
||||
if (status === "completed") return "bg-emerald-400";
|
||||
if (status === "failed") return "bg-destructive";
|
||||
if (status === "cancelled") return "bg-yellow-500";
|
||||
return "bg-muted-foreground";
|
||||
}
|
||||
|
||||
function modelLabel(thread: SessionThread): string {
|
||||
if (thread.provider && thread.model) {
|
||||
return `${thread.provider}:${thread.model}`;
|
||||
}
|
||||
return thread.model || thread.provider || "No model";
|
||||
}
|
||||
|
||||
function tokensLabel(thread: SessionThread): string {
|
||||
if (thread.inputTokens == null && thread.outputTokens == null) {
|
||||
return "-";
|
||||
}
|
||||
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
|
||||
}
|
||||
|
||||
function sessionFilterDetails(
|
||||
thread: SessionThread,
|
||||
session?: SessionHistoryItem,
|
||||
): string[] {
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
thread.model ? `model:${thread.model}` : undefined,
|
||||
].filter((detail): detail is string => Boolean(detail));
|
||||
}
|
||||
|
||||
function sortTimestamp(session?: SessionHistoryItem) {
|
||||
const timestamp = parseTimestamp(session?.endedAt || session?.startedAt);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
|
||||
const [sortDirection, setSortDirection] = useState<"newest" | "oldest">(
|
||||
"newest",
|
||||
);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
history.threads.flatMap((thread) =>
|
||||
sessionFilterDetails(thread, history.sessionById.get(thread.id)),
|
||||
),
|
||||
),
|
||||
).sort((a, b) => a.localeCompare(b)),
|
||||
[history.sessionById, history.threads],
|
||||
);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const selected = new Set(sessionFilters);
|
||||
const filtered = history.threads.filter((thread) => {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const details = sessionFilterDetails(thread, session);
|
||||
const matchesFilters =
|
||||
selected.size === 0 || details.some((detail) => selected.has(detail));
|
||||
if (!matchesFilters) {
|
||||
return false;
|
||||
}
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
const searchable = [
|
||||
thread.title,
|
||||
thread.codebase,
|
||||
thread.provider,
|
||||
thread.model,
|
||||
session?.workspaceRoot,
|
||||
session?.cwd,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(normalizedQuery);
|
||||
});
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aTime = sortTimestamp(history.sessionById.get(a.id));
|
||||
const bTime = sortTimestamp(history.sessionById.get(b.id));
|
||||
return sortDirection === "newest" ? bTime - aTime : aTime - bTime;
|
||||
});
|
||||
}, [
|
||||
history.sessionById,
|
||||
history.threads,
|
||||
query,
|
||||
sessionFilters,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const toggleFilter = (detail: string, checked: boolean) => {
|
||||
setSessionFilters((current) => {
|
||||
if (checked) {
|
||||
return current.includes(detail) ? current : [...current, detail];
|
||||
}
|
||||
return current.filter((item) => item !== detail);
|
||||
});
|
||||
};
|
||||
|
||||
const startRename = (thread: SessionThread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
setEditingTitle(thread.title);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
setEditingTitle("");
|
||||
};
|
||||
|
||||
const submitRename = async (thread: SessionThread) => {
|
||||
const renamed = await history.renameThread(thread.id, editingTitle);
|
||||
if (renamed) {
|
||||
cancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteCandidate) {
|
||||
return;
|
||||
}
|
||||
const deleted = await history.deleteThread(deleteCandidate.id);
|
||||
if (deleted) {
|
||||
setDeleteCandidate(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Recent sessions across clients and workspaces.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="relative min-w-44 max-w-72 flex-1">
|
||||
<Search className="-translate-y-1/2 pointer-events-none absolute left-2.5 top-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search sessions"
|
||||
className="h-8 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Sort sessions"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
size="sm"
|
||||
title="Sort sessions"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowUpDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
|
||||
{sortDirection === "newest" ? "Newest first" : "Newest first"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
|
||||
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Filter sessions"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
size="sm"
|
||||
title="Filter sessions"
|
||||
type="button"
|
||||
variant={sessionFilters.length > 0 ? "default" : "outline"}
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-72 w-72">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Filter sessions</DropdownMenuLabel>
|
||||
{sessionFilters.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setSessionFilters([])}>
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{filterOptions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
No filters available
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filterOptions.map((detail) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sessionFilters.includes(detail)}
|
||||
key={detail}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleFilter(detail, checked === true)
|
||||
}
|
||||
>
|
||||
<span className="truncate" title={detail}>
|
||||
{detail}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
||||
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Session</span>
|
||||
<span>Workspace</span>
|
||||
<span>Model</span>
|
||||
<span>Tokens</span>
|
||||
<span>Cost</span>
|
||||
<span>Updated</span>
|
||||
<span />
|
||||
</div>
|
||||
<div>
|
||||
{history.isLoadingHistory && history.threads.length === 0 ? (
|
||||
<div className="flex items-center gap-2 border-t px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading session history...
|
||||
</div>
|
||||
) : null}
|
||||
{!history.isLoadingHistory && filteredThreads.length === 0 ? (
|
||||
<div className="border-t px-4 py-8 text-sm text-muted-foreground">
|
||||
{history.threads.length === 0
|
||||
? "No sessions yet."
|
||||
: "No sessions match the current filters."}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredThreads.map((thread) => {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const isEditing = editingSessionId === thread.id;
|
||||
const isPending = history.pendingAction?.sessionId === thread.id;
|
||||
const pendingKind = isPending
|
||||
? history.pendingAction?.action
|
||||
: null;
|
||||
const workspace = session?.workspaceRoot || session?.cwd || "";
|
||||
const updated = formatRelativeTime(
|
||||
session?.endedAt || session?.startedAt,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
|
||||
activeSessionId === thread.id
|
||||
? "bg-accent/50"
|
||||
: "hover:bg-accent/30",
|
||||
)}
|
||||
key={thread.id}
|
||||
>
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="col-span-6 grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitRename(thread);
|
||||
}}
|
||||
>
|
||||
<div className="col-span-2 flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
aria-label={`Rename ${thread.title}`}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
disabled={pendingKind === "rename"}
|
||||
onChange={(event) =>
|
||||
setEditingTitle(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
value={editingTitle}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Save title"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={
|
||||
pendingKind === "rename" || !editingTitle.trim()
|
||||
}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{pendingKind === "rename" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Cancel rename"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={pendingKind === "rename"}
|
||||
onClick={cancelRename}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{tokensLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatCostUsd(thread.totalCostUsd) ?? "-"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{updated || thread.time}
|
||||
</span>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
|
||||
disabled={Boolean(pendingKind)}
|
||||
onClick={() => {
|
||||
if (pendingKind) {
|
||||
return;
|
||||
}
|
||||
// Don't open the session when the user is selecting text.
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
history.openThread(thread.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3 font-semibold">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
statusTone(thread.status),
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={workspace}>
|
||||
{workspace ? basenamePath(workspace) : "No workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{tokensLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatCostUsd(thread.totalCostUsd) ?? "-"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{updated || thread.time}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
{pendingKind ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{history.mayHaveMoreSessions ? (
|
||||
<div className="border-t px-4 py-3">
|
||||
<Button
|
||||
className="h-8 rounded-md px-3 text-xs"
|
||||
disabled={history.isLoadingMore}
|
||||
onClick={() =>
|
||||
void history.loadMoreSessions(history.threads.length + 100)
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{history.isLoadingMore ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteCandidate !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && history.pendingAction?.action !== "delete") {
|
||||
setDeleteCandidate(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes "{deleteCandidate?.title ?? "this session"}" from
|
||||
local history.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={history.pendingAction?.action === "delete"}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={
|
||||
!deleteCandidate || history.pendingAction?.action === "delete"
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void confirmDelete();
|
||||
}}
|
||||
>
|
||||
{history.pendingAction?.action === "delete" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
"use client";
|
||||
|
||||
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CommandBadge,
|
||||
PageEmptyState,
|
||||
PageFrame,
|
||||
PageHeader,
|
||||
} from "../page-layout";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type ConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type ActiveConnector = {
|
||||
id: string;
|
||||
type: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
startedAt?: string;
|
||||
applicationId?: string;
|
||||
botUsername?: string;
|
||||
userName?: string;
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
type ConnectorChannelsResponse = {
|
||||
available: ConnectorChannel[];
|
||||
active: ActiveConnector[];
|
||||
};
|
||||
|
||||
type ConnectorFormState = {
|
||||
channelId: string;
|
||||
values: Record<string, string>;
|
||||
securityEnabled: boolean;
|
||||
securityValues: Record<string, string>;
|
||||
};
|
||||
|
||||
function connectorName(
|
||||
connector: ActiveConnector,
|
||||
channels: ConnectorChannel[],
|
||||
): string {
|
||||
return (
|
||||
channels.find((channel) => channel.id === connector.type)?.name ??
|
||||
connector.type
|
||||
);
|
||||
}
|
||||
|
||||
function connectorIdentity(connector: ActiveConnector): string {
|
||||
if (connector.botUsername) {
|
||||
return `@${connector.botUsername}`;
|
||||
}
|
||||
if (connector.userName) {
|
||||
return connector.userName;
|
||||
}
|
||||
if (connector.applicationId) {
|
||||
return connector.applicationId;
|
||||
}
|
||||
return `pid ${connector.pid}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function isSecretField(
|
||||
field: ConnectorField | ConnectorSecurityField,
|
||||
): boolean {
|
||||
const label = field.label.toLowerCase();
|
||||
const key =
|
||||
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
|
||||
return (
|
||||
label.includes("token") ||
|
||||
label.includes("secret") ||
|
||||
label.includes("key") ||
|
||||
key.includes("token") ||
|
||||
key.includes("secret") ||
|
||||
key.includes("key")
|
||||
);
|
||||
}
|
||||
|
||||
function isMultilineField(field: ConnectorField): boolean {
|
||||
const label = field.label.toLowerCase();
|
||||
return label.includes("json") || field.flag.includes("credentials");
|
||||
}
|
||||
|
||||
function shouldIncludeField(
|
||||
field: ConnectorField,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialValuesForChannel(
|
||||
channel?: ConnectorChannel,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of channel?.fields ?? []) {
|
||||
if (field.initialValue) {
|
||||
values[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
|
||||
const channel = channels[0];
|
||||
return {
|
||||
channelId: channel?.id ?? "",
|
||||
values: initialValuesForChannel(channel),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function ChannelsContent() {
|
||||
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
|
||||
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [busyChannel, setBusyChannel] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<ConnectorFormState>({
|
||||
channelId: "",
|
||||
values: {},
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedChannel = useMemo(
|
||||
() => channels.find((channel) => channel.id === formState.channelId),
|
||||
[channels, formState.channelId],
|
||||
);
|
||||
const visibleFields = useMemo(() => {
|
||||
const values = {
|
||||
...initialValuesForChannel(selectedChannel),
|
||||
...formState.values,
|
||||
};
|
||||
return (selectedChannel?.fields ?? []).filter((field) =>
|
||||
shouldIncludeField(field, values),
|
||||
);
|
||||
}, [selectedChannel, formState.values]);
|
||||
|
||||
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
|
||||
setChannels(response.available);
|
||||
setActiveConnectors(response.active);
|
||||
setFormState((prev) =>
|
||||
prev.channelId ? prev : createFormState(response.available),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshChannels = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"list_connector_channels",
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshChannels();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshChannels]);
|
||||
|
||||
const openAddDialog = () => {
|
||||
setFormState(createFormState(channels));
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const updateFieldValue = (flag: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
values: { ...prev.values, [flag]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSecurityFieldValue = (key: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
securityValues: { ...prev.securityValues, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const startConnector = async () => {
|
||||
if (!selectedChannel) {
|
||||
setFormError("Choose a channel");
|
||||
return;
|
||||
}
|
||||
for (const field of selectedChannel.fields) {
|
||||
if (!visibleFields.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field.required && !formState.values[field.flag]?.trim()) {
|
||||
setFormError(`${field.label} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (formState.securityEnabled && selectedChannel.security) {
|
||||
for (const field of selectedChannel.security.fields) {
|
||||
if (!formState.securityValues[field.key]?.trim()) {
|
||||
setFormError(field.requiredMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setBusyChannel(selectedChannel.id);
|
||||
setFormError(null);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"start_connector_channel",
|
||||
{
|
||||
channel: selectedChannel.id,
|
||||
values: formState.values,
|
||||
security: {
|
||||
enabled: formState.securityEnabled,
|
||||
values: formState.securityValues,
|
||||
},
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setFormError(message);
|
||||
} finally {
|
||||
setBusyChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stopConnector = async (connector: ActiveConnector) => {
|
||||
setBusyChannel(connector.type);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"stop_connector_channel",
|
||||
{ channel: connector.type },
|
||||
);
|
||||
applyResponse(response);
|
||||
setRemoveTarget(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline.`}
|
||||
title="Channels"
|
||||
meta={<CommandBadge>cline connect</CommandBadge>}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshChannels()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={channels.length === 0}
|
||||
onClick={openAddDialog}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<PageEmptyState>Loading channels...</PageEmptyState>
|
||||
) : activeConnectors.length === 0 ? (
|
||||
<PageEmptyState>No channels connected.</PageEmptyState>
|
||||
) : (
|
||||
<section className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid gap-2 p-2.5">
|
||||
{activeConnectors.map((connector) => (
|
||||
<div
|
||||
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
key={connector.id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
|
||||
<p className="truncate text-[13px] font-semibold leading-tight">
|
||||
{connectorName(connector, channels)}
|
||||
</p>
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{connectorIdentity(connector)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
pid={connector.pid}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.hubUrl}
|
||||
>
|
||||
{connector.hubUrl}
|
||||
</span>
|
||||
{connector.baseUrl ? (
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.baseUrl}
|
||||
>
|
||||
{connector.baseUrl}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busyChannel === connector.type}
|
||||
onClick={() => setRemoveTarget(connector)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove...
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a connector channel for Cline.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Channel</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setFormState({
|
||||
channelId: value,
|
||||
values: initialValuesForChannel(
|
||||
channels.find((channel) => channel.id === value),
|
||||
),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
}}
|
||||
value={formState.channelId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{visibleFields.map((field) => (
|
||||
<div className="grid gap-2" key={field.flag}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<span className="text-destructive"> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{field.options ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
updateFieldValue(field.flag, value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
formState.values[field.flag] ?? field.initialValue ?? ""
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isMultilineField(field) ? (
|
||||
<Textarea
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
rows={5}
|
||||
value={formState.values[field.flag] ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
type={isSecretField(field) ? "password" : "text"}
|
||||
value={formState.values[field.flag] ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedChannel?.security ? (
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-sm">Restrict access</Label>
|
||||
<Switch
|
||||
checked={formState.securityEnabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
securityEnabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{formState.securityEnabled
|
||||
? selectedChannel.security.fields.map((field) => (
|
||||
<div className="grid gap-2" key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateSecurityFieldValue(
|
||||
field.key,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
type={isSecretField(field) ? "password" : "text"}
|
||||
value={formState.securityValues[field.key] ?? ""}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={busyChannel !== null}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busyChannel !== null || !selectedChannel}
|
||||
onClick={() => void startConnector()}
|
||||
type="button"
|
||||
>
|
||||
{busyChannel ? "Starting..." : "Add Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={removeTarget !== null}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Channel</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Confirm that you want to stop the active{" "}
|
||||
{removeTarget ? connectorName(removeTarget, channels) : "channel"}{" "}
|
||||
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busyChannel !== null}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busyChannel !== null || !removeTarget}
|
||||
onClick={() => {
|
||||
if (removeTarget) {
|
||||
void stopConnector(removeTarget);
|
||||
}
|
||||
}}
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
+1063
-592
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
@@ -203,7 +203,10 @@ export function McpServersContent() {
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshServers();
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshServers();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshServers]);
|
||||
|
||||
const toggleServer = async (server: McpServer, disabled: boolean) => {
|
||||
@@ -402,18 +405,24 @@ export function McpServersContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 className="truncate text-lg font-semibold text-foreground">
|
||||
MCP Servers
|
||||
</h2>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={
|
||||
hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."
|
||||
}
|
||||
title="MCP Servers"
|
||||
meta={
|
||||
<>
|
||||
<CommandBadge>cline config mcp</CommandBadge>
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
From settings file
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -423,151 +432,138 @@ export function McpServersContent() {
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
{hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."}
|
||||
</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Command:
|
||||
</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers &&
|
||||
Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Headers:
|
||||
</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Command:</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers && Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Headers:</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -852,6 +848,6 @@ export function McpServersContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+292
-116
@@ -2,18 +2,21 @@
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Search,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -82,94 +85,146 @@ function coerceFieldValue(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function assignSettingsPath(
|
||||
target: Record<string, unknown>,
|
||||
path: string,
|
||||
value: ProviderConfigFieldPrimitive,
|
||||
) {
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) return;
|
||||
let cursor = target;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = cursor[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Record<string, unknown>;
|
||||
}
|
||||
const last = segments.at(-1);
|
||||
if (last) {
|
||||
cursor[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function toSettingsPatch(
|
||||
values: Record<string, ProviderConfigFieldPrimitive>,
|
||||
): Record<string, unknown> {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
assignSettingsPath(settings, path, value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function ProviderListContent({
|
||||
providers,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
selectedProviderId,
|
||||
variant = "page",
|
||||
}: {
|
||||
providers: Provider[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfigure: (id: string) => void;
|
||||
onAddProvider: () => void;
|
||||
selectedProviderId?: string | null;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
const [providerSearch, setProviderSearch] = useState("");
|
||||
const enabledProviderCount = providers.filter(
|
||||
(provider) => provider.enabled,
|
||||
).length;
|
||||
const providerSearchQuery = providerSearch.trim().toLowerCase();
|
||||
const filteredProviders = providerSearchQuery
|
||||
? providers.filter((provider) =>
|
||||
provider.name.toLowerCase().includes(providerSearchQuery),
|
||||
)
|
||||
: providers;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Model Providers
|
||||
</h2>
|
||||
<Button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
|
||||
onClick={onAddProvider}
|
||||
variant="ghost"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Model Providers
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
{providers.length} available · {enabledProviderCount}{" "}
|
||||
enabled
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
|
||||
<Button
|
||||
aria-label="Search providers"
|
||||
className="size-8 rounded-md"
|
||||
onClick={() => setProviderSearchOpen((open) => !open)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant={providerSearchOpen ? "default" : "secondary"}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
|
||||
onClick={onAddProvider}
|
||||
type="button"
|
||||
>
|
||||
<PlusCircle className="size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
|
||||
{providers.map((prov) => (
|
||||
{providerSearchOpen ? (
|
||||
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
|
||||
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search model providers"
|
||||
autoFocus
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm"
|
||||
onChange={(event) => setProviderSearch(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
value={providerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
|
||||
No providers match "{providerSearch.trim()}".
|
||||
</div>
|
||||
) : null}
|
||||
{filteredProviders.map((prov) => (
|
||||
<div
|
||||
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
|
||||
selectedProviderId === prov.id && "bg-accent/45",
|
||||
)}
|
||||
key={prov.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
|
||||
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -187,6 +242,7 @@ export function ProviderDetailContent({
|
||||
modelsError,
|
||||
onOAuthLogin,
|
||||
oauthLoginPending = false,
|
||||
variant = "page",
|
||||
}: {
|
||||
provider: Provider;
|
||||
onBack: () => void;
|
||||
@@ -196,18 +252,49 @@ export function ProviderDetailContent({
|
||||
modelsError?: string | null;
|
||||
onOAuthLogin?: () => void;
|
||||
oauthLoginPending?: boolean;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
Record<string, ProviderConfigFieldPrimitive>
|
||||
>(() => getInitialConfigValues(provider));
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfigValues(getInitialConfigValues(provider));
|
||||
}, [provider]);
|
||||
const [modelSearchState, setModelSearchState] = useState<{
|
||||
providerId: string;
|
||||
value: string;
|
||||
} | null>(null);
|
||||
const [copiedModelState, setCopiedModelState] = useState<{
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
} | null>(null);
|
||||
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const configFields = provider.configFields ?? [];
|
||||
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
|
||||
const modelList = provider.modelList ?? [];
|
||||
const modelSearch =
|
||||
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
|
||||
const copiedModelId =
|
||||
copiedModelState?.providerId === provider.id
|
||||
? copiedModelState.modelId
|
||||
: null;
|
||||
const modelSearchQuery = modelSearch.trim().toLowerCase();
|
||||
const filteredModelList = modelSearchQuery
|
||||
? modelList.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(modelSearchQuery) ||
|
||||
model.id.toLowerCase().includes(modelSearchQuery),
|
||||
)
|
||||
: modelList;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const commitField = (
|
||||
field: ProviderConfigField,
|
||||
@@ -232,46 +319,83 @@ export function ProviderDetailContent({
|
||||
onUpdate(updates);
|
||||
};
|
||||
|
||||
const copyModelId = (modelId: string) => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(modelId).then(() => {
|
||||
setCopiedModelState({ modelId, providerId: provider.id });
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
copiedModelTimeoutRef.current = window.setTimeout(
|
||||
() => setCopiedModelState(null),
|
||||
1600,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
{/* Back + title */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<Button
|
||||
aria-label="Back to providers"
|
||||
aria-label={
|
||||
isPanel ? "Close provider details" : "Back to providers"
|
||||
}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{isPanel ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{configFields.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
<section
|
||||
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{configFields.map((field) => {
|
||||
const value = localConfigValues[field.path];
|
||||
const valueText = fieldValueToString(value);
|
||||
const isSecret = field.type === "password" || field.secret;
|
||||
const isShown = shownSecrets[field.path] ?? false;
|
||||
return (
|
||||
<div key={field.path}>
|
||||
<header className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
<div
|
||||
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
|
||||
key={field.path}
|
||||
>
|
||||
<header>
|
||||
<h3 className="text-[17px] font-semibold text-foreground">
|
||||
{field.label}
|
||||
</h3>
|
||||
{field.description ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{field.type === "boolean" ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
@@ -284,7 +408,7 @@ export function ProviderDetailContent({
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
commitField(field, event.target.value)
|
||||
}
|
||||
@@ -301,12 +425,12 @@ export function ProviderDetailContent({
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
|
||||
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
|
||||
{field.type === "url" ? (
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Input
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onBlur={() => commitField(field, valueText)}
|
||||
onChange={(event) =>
|
||||
setLocalConfigValues((current) => ({
|
||||
@@ -392,10 +516,18 @@ export function ProviderDetailContent({
|
||||
) : null}
|
||||
|
||||
{/* Models section */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Models</h3>
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isPanel ? "max-w-none" : "max-w-[46rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
|
||||
<h2 className="text-[17px] font-medium text-muted-foreground">
|
||||
Models
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
aria-label="Refresh models"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
@@ -414,39 +546,83 @@ export function ProviderDetailContent({
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-destructive">{modelsError}</p>
|
||||
</div>
|
||||
) : provider.modelList && provider.modelList.length > 0 ? (
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
|
||||
{provider.modelList.map((model) => (
|
||||
<div
|
||||
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
{/* Model name */}
|
||||
<span className="flex-1 text-sm text-foreground font-mono">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{model.name}
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{model.supportsVision && (
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
aria-label={`Favorite ${model.name}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
) : modelList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search models"
|
||||
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
onChange={(event) =>
|
||||
setModelSearchState({
|
||||
providerId: provider.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Search models by name or ID"
|
||||
spellCheck={false}
|
||||
value={modelSearch}
|
||||
/>
|
||||
</div>
|
||||
{filteredModelList.length > 0 ? (
|
||||
<div className="max-h-125 overflow-y-scroll border-t">
|
||||
{filteredModelList.map((model) => (
|
||||
<div
|
||||
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 font-mono">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<div title="File Support">
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{model.supportsVision && (
|
||||
<div title="Image Support">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={`Copy model ID ${model.id}`}
|
||||
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => copyModelId(model.id)}
|
||||
title="Copy model ID"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate">{model.id}</span>
|
||||
<Copy className="size-3 shrink-0" />
|
||||
{copiedModelId === model.id ? (
|
||||
<span className="shrink-0 text-foreground">
|
||||
Copied
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
aria-label={`Favorite ${model.name}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No models match "{modelSearch.trim()}".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import type { ProviderConfigFieldPrimitive } from "@/lib/provider-schema";
|
||||
|
||||
function assignSettingsPath(
|
||||
target: Record<string, unknown>,
|
||||
path: string,
|
||||
value: ProviderConfigFieldPrimitive,
|
||||
) {
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) return;
|
||||
let cursor = target;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = cursor[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Record<string, unknown>;
|
||||
}
|
||||
const last = segments.at(-1);
|
||||
if (last) {
|
||||
cursor[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function toSettingsPatch(
|
||||
values: Record<string, ProviderConfigFieldPrimitive>,
|
||||
): Record<string, unknown> {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
assignSettingsPath(settings, path, value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
Provider,
|
||||
@@ -11,20 +10,25 @@ import type {
|
||||
ProviderModelsResponse,
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubTheme,
|
||||
readStoredHubTheme,
|
||||
readSystemHubTheme,
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { primeExtensionsListsCache, RulesView } from "./extensions-view";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { CustomizationSectionView, RulesView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
toSettingsPatch,
|
||||
} from "./provider-list-view";
|
||||
import {
|
||||
primeRoutineOverviewCache,
|
||||
RoutineSchedulesContent,
|
||||
} from "./routine-view";
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Settings nav categories
|
||||
@@ -33,14 +37,15 @@ import {
|
||||
const navCategories = [
|
||||
"General",
|
||||
"Providers",
|
||||
"Extensions",
|
||||
"MCP",
|
||||
"Routine",
|
||||
"Features",
|
||||
"Marketplace",
|
||||
"Extensions",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
type NavCategory = (typeof navCategories)[number];
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -53,9 +58,18 @@ let providerCatalogCache: {
|
||||
// Component
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
const [activeNav, setActiveNav] = useState<NavCategory>("Providers");
|
||||
const [providersExpanded, setProvidersExpanded] = useState(true);
|
||||
export function SettingsView({
|
||||
chrome = "full",
|
||||
initialSection = "General",
|
||||
onClose,
|
||||
onNavigateSection,
|
||||
}: {
|
||||
chrome?: "full" | "content";
|
||||
initialSection?: SettingsSection;
|
||||
onClose: () => void;
|
||||
onNavigateSection?: (section: SettingsSection) => void;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
() => providerCatalogCache?.providers ?? [],
|
||||
);
|
||||
@@ -125,14 +139,14 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviderCatalog();
|
||||
void primeRoutineOverviewCache().catch(() => {
|
||||
// Keep settings responsive even if routine prefetch fails.
|
||||
});
|
||||
void primeExtensionsListsCache().catch(() => {
|
||||
// Keep settings responsive even if extension prefetch fails.
|
||||
});
|
||||
}, [loadProviderCatalog]);
|
||||
if (activeNav !== "Providers") {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderCatalog();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [activeNav, loadProviderCatalog]);
|
||||
|
||||
const persistProviderSettings = useCallback(
|
||||
async (
|
||||
@@ -237,13 +251,12 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
[setProvidersWithCache],
|
||||
);
|
||||
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
const selectedProvider = selectedProviderId
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -276,6 +289,7 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const openProviderDetail = (id: string) => {
|
||||
setActiveNav("Providers");
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(id);
|
||||
};
|
||||
|
||||
@@ -289,10 +303,14 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
if (!selected || (selected.modelList?.length ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
void loadProviderModels(selectedProviderId);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderModels(selectedProviderId);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderModels, providers, selectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
@@ -319,10 +337,102 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
);
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const selectSection = (section: SettingsSection) => {
|
||||
setActiveNav(section);
|
||||
onNavigateSection?.(section);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : selectedProvider ? (
|
||||
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
/>
|
||||
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
usesOAuth(selectedProvider)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
|
||||
provider={selectedProvider}
|
||||
variant="panel"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Providers" ? (
|
||||
providerContent
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
|
||||
) : activeNav === "Extensions" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
<GeneralSettingsContent />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (chrome === "content") {
|
||||
return (
|
||||
<div className="h-full overflow-hidden bg-background">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header bar */}
|
||||
@@ -344,144 +454,68 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
<nav className="w-56 shrink-0 border-r border-border">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{navCategories.map((cat) => {
|
||||
if (cat === "Providers") {
|
||||
return (
|
||||
<div key={cat}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
|
||||
activeNav === "Providers"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveNav("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
setProvidersExpanded((p) => !p);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<span>Providers</span>
|
||||
{providersExpanded ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
{providersExpanded && (
|
||||
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-border pl-2">
|
||||
{enabledProviders.map((prov) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
selectedProviderId === prov.id
|
||||
? "bg-accent/80 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/30",
|
||||
)}
|
||||
disabled={oauthSigningProviderId === prov.id}
|
||||
key={prov.id}
|
||||
onClick={() => openProviderDetail(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate">{prov.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat && !selectedProviderId
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
setActiveNav(cat);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{navCategories.map((cat) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
selectSection(cat);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeNav === "Providers" && selectedProvider ? (
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={
|
||||
modelsLoadingByProvider[selectedProvider.id] ?? false
|
||||
}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) =>
|
||||
updateProvider(selectedProvider.id, updates)
|
||||
}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
) : activeNav === "Providers" ? (
|
||||
addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading providers...
|
||||
</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
)
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Routine" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Extensions" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSettingsContent() {
|
||||
const [theme, setTheme] = useState<HubTheme>(() => {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Manage desktop preferences for this browser and CLI environment."
|
||||
title="Settings"
|
||||
/>
|
||||
<section className="max-w-[86rem]">
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Dark mode
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Keep the desktop interface in dark mode on this browser.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Dark mode"
|
||||
checked={theme === "dark"}
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
mode: "act",
|
||||
systemPrompt: undefined,
|
||||
maxIterations: undefined,
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
enableTools: true,
|
||||
enableSpawn: undefined,
|
||||
enableTeams: undefined,
|
||||
|
||||
@@ -116,10 +116,13 @@ export function normalizeRuntimeConfig(
|
||||
): ChatSessionConfig {
|
||||
const normalizedWorkspaceRoot = config.workspaceRoot.trim();
|
||||
const normalizedCwd = (config.cwd?.trim() || normalizedWorkspaceRoot).trim();
|
||||
const thinking = config.reasoningEffort ? true : config.thinking;
|
||||
return {
|
||||
...config,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
cwd: normalizedCwd || normalizedWorkspaceRoot,
|
||||
thinking,
|
||||
reasoningEffort: thinking === false ? undefined : config.reasoningEffort,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
};
|
||||
|
||||
@@ -101,7 +101,11 @@ export type ChatWsChunkEvent = {
|
||||
event: AgentChunkEvent;
|
||||
};
|
||||
|
||||
export type ChatTransportState = "connecting" | "reconnecting" | "connected";
|
||||
export type ChatTransportState =
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
|
||||
export type CoreLogChunk = {
|
||||
level?: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildSessionDiffState,
|
||||
type SessionHookEvent,
|
||||
EMPTY_DIFF_SUMMARY,
|
||||
type SessionDiffSummary,
|
||||
type SessionFileDiff,
|
||||
@@ -241,6 +242,9 @@ export function useChatSession() {
|
||||
const hydrationRequestIdRef = useRef(0);
|
||||
const [chatTransportState, setChatTransportState] =
|
||||
useState<ChatTransportState>(desktopClient.getTransportState());
|
||||
const [chatTransportError, setChatTransportError] = useState<string | null>(
|
||||
desktopClient.getTransportError(),
|
||||
);
|
||||
// ---- Ref syncs ----
|
||||
|
||||
useEffect(() => {
|
||||
@@ -493,6 +497,50 @@ export function useChatSession() {
|
||||
void refreshPromptsInQueue(sessionId);
|
||||
}, [refreshPromptsInQueue, refreshSessionDiffSummary, sessionId]);
|
||||
|
||||
// Fallback for sessions with no tool events in the hook log (e.g. sessions
|
||||
// recorded before tool_call/tool_result hook logging existed): rebuild the
|
||||
// diff state from the tool messages themselves.
|
||||
useEffect(() => {
|
||||
if (!sessionId || fileDiffs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const events: SessionHookEvent[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.sessionId !== sessionId || message.role !== "tool") {
|
||||
continue;
|
||||
}
|
||||
let payload: {
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
} | null = null;
|
||||
try {
|
||||
payload = JSON.parse(message.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!payload?.toolName || payload.result == null || payload.isError) {
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
hookName: "tool_result",
|
||||
toolName: payload.toolName,
|
||||
toolInput: payload.input,
|
||||
toolOutput: payload.result,
|
||||
});
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
const diffState = buildSessionDiffState(events);
|
||||
if (diffState.fileDiffs.length === 0) {
|
||||
return;
|
||||
}
|
||||
setFileDiffs(diffState.fileDiffs);
|
||||
setDiffSummary(diffState.summary);
|
||||
}, [sessionId, messages, fileDiffs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeSessionId = sessionId;
|
||||
if (!activeSessionId) {
|
||||
@@ -790,7 +838,10 @@ export function useChatSession() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeTransport = desktopClient.subscribeTransportState(
|
||||
setChatTransportState,
|
||||
(state) => {
|
||||
setChatTransportState(state);
|
||||
setChatTransportError(desktopClient.getTransportError());
|
||||
},
|
||||
);
|
||||
const unsubscribeEvents = desktopClient.subscribe(
|
||||
"chat_event",
|
||||
@@ -1669,6 +1720,7 @@ export function useChatSession() {
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState,
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ export const ChatSessionConfigSchema = z.object({
|
||||
systemPrompt: z.string().optional(),
|
||||
rules: z.string().optional(),
|
||||
maxIterations: z.number().int().positive().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
enableTools: z.boolean(),
|
||||
enableSpawn: z.boolean().optional(),
|
||||
enableTeams: z.boolean().optional(),
|
||||
|
||||
@@ -18,8 +18,9 @@ async function tryTauriInvoke<T>(
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return await invoke<T>(command, args);
|
||||
} catch {
|
||||
throw new Error(`Tauri invoke unavailable for command: ${command}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Tauri invoke failed for ${command}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +38,7 @@ let resolvedEndpointCache: string | null = null;
|
||||
* 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`).
|
||||
*/
|
||||
async function resolveBackendEndpoint(): Promise<string> {
|
||||
export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
if (resolvedEndpointCache) return resolvedEndpointCache;
|
||||
|
||||
// 1. Explicit injection from sidecar or test harness.
|
||||
@@ -51,7 +52,7 @@ async function resolveBackendEndpoint(): Promise<string> {
|
||||
}
|
||||
|
||||
// 2. Tauri command (full desktop app).
|
||||
try {
|
||||
if (isTauriAvailable()) {
|
||||
const endpoint = await tryTauriInvoke<string>(
|
||||
"get_desktop_backend_endpoint",
|
||||
);
|
||||
@@ -60,15 +61,26 @@ async function resolveBackendEndpoint(): Promise<string> {
|
||||
resolvedEndpointCache = trimmed;
|
||||
return resolvedEndpointCache;
|
||||
}
|
||||
} catch {
|
||||
// Tauri not available — fall through to default.
|
||||
throw new Error("Tauri returned an empty desktop backend endpoint");
|
||||
}
|
||||
|
||||
// 3. Default sidecar port for local dev mode.
|
||||
// 3. Default sidecar port for local dev mode without the Tauri bridge.
|
||||
resolvedEndpointCache = "ws://127.0.0.1:3126/transport";
|
||||
return resolvedEndpointCache;
|
||||
}
|
||||
|
||||
export async function resolveDesktopBackendHttpEndpoint(): Promise<string> {
|
||||
const wsEndpoint = await resolveDesktopBackendWsEndpoint();
|
||||
const endpoint = new URL(wsEndpoint);
|
||||
endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:";
|
||||
if (endpoint.pathname.endsWith("/transport")) {
|
||||
endpoint.pathname = endpoint.pathname.slice(0, -"/transport".length);
|
||||
}
|
||||
endpoint.search = "";
|
||||
endpoint.hash = "";
|
||||
return endpoint.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -102,6 +114,7 @@ class DesktopClient {
|
||||
private handlers = new Map<string, Set<EventHandler>>();
|
||||
private transportStateHandlers = new Set<TransportStateHandler>();
|
||||
private transportState: DesktopTransportState = "connecting";
|
||||
private transportError: string | null = null;
|
||||
private hasConnectedOnce = false;
|
||||
private endpoint: string | null = null;
|
||||
|
||||
@@ -116,7 +129,7 @@ class DesktopClient {
|
||||
if (this.endpoint?.trim()) {
|
||||
return this.endpoint;
|
||||
}
|
||||
const endpoint = await resolveBackendEndpoint();
|
||||
const endpoint = await resolveDesktopBackendWsEndpoint();
|
||||
this.endpoint = endpoint;
|
||||
return this.endpoint;
|
||||
}
|
||||
@@ -203,6 +216,7 @@ class DesktopClient {
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.hasConnectedOnce = true;
|
||||
this.transportError = null;
|
||||
this.setTransportState("connected");
|
||||
resolve();
|
||||
};
|
||||
@@ -217,7 +231,9 @@ class DesktopClient {
|
||||
this.socket = null;
|
||||
}
|
||||
if (this.transportState !== "connected") {
|
||||
reject(new Error("Desktop backend transport unavailable"));
|
||||
reject(
|
||||
new Error(`Desktop backend transport unavailable at ${endpoint}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.setTransportState("reconnecting");
|
||||
@@ -225,9 +241,18 @@ class DesktopClient {
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
});
|
||||
})().finally(() => {
|
||||
this.connectPromise = null;
|
||||
});
|
||||
})()
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.transportError = message;
|
||||
if (!this.hasConnectedOnce) {
|
||||
this.setTransportState("unavailable");
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.connectPromise = null;
|
||||
});
|
||||
|
||||
return this.connectPromise;
|
||||
}
|
||||
@@ -307,6 +332,10 @@ class DesktopClient {
|
||||
getTransportState(): DesktopTransportState {
|
||||
return this.transportState;
|
||||
}
|
||||
|
||||
getTransportError(): string | null {
|
||||
return this.transportError;
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopClient = new DesktopClient();
|
||||
|
||||
@@ -25,7 +25,11 @@ export type DesktopTransportMessage =
|
||||
| DesktopTransportResponse
|
||||
| DesktopTransportEvent;
|
||||
|
||||
export type DesktopTransportState = "connecting" | "reconnecting" | "connected";
|
||||
export type DesktopTransportState =
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
|
||||
export type DesktopBackendReadyPayload = {
|
||||
endpoint: string;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { resolveDesktopBackendHttpEndpoint } from "@/lib/desktop-client";
|
||||
|
||||
export type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
|
||||
export type MarketplaceTag = {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type MarketplaceEnvVar = {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceEntry = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name: string;
|
||||
featured?: boolean;
|
||||
tagline: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
install: {
|
||||
args: string[];
|
||||
env?: MarketplaceEnvVar[];
|
||||
notes?: string;
|
||||
command: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MarketplaceCatalog = {
|
||||
version: number;
|
||||
generatedAt?: string;
|
||||
baseUrl?: string;
|
||||
counts: {
|
||||
total: number;
|
||||
plugins: number;
|
||||
skills: number;
|
||||
mcps: number;
|
||||
};
|
||||
tags: MarketplaceTag[];
|
||||
entries: MarketplaceEntry[];
|
||||
};
|
||||
|
||||
const MARKETPLACE_CATALOG_URL = "/api/marketplace/catalog";
|
||||
|
||||
const EMPTY_CATALOG: MarketplaceCatalog = {
|
||||
version: 1,
|
||||
counts: {
|
||||
total: 0,
|
||||
plugins: 0,
|
||||
skills: 0,
|
||||
mcps: 0,
|
||||
},
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
|
||||
return value === "mcp" || value === "skill" || value === "plugin";
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseCount(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function parseEnv(value: unknown): MarketplaceEnvVar[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const env = value
|
||||
.map((item): MarketplaceEnvVar | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
if (typeof candidate.name !== "string") return null;
|
||||
const parsed: MarketplaceEnvVar = {
|
||||
name: candidate.name,
|
||||
};
|
||||
if (typeof candidate.required === "boolean") {
|
||||
parsed.required = candidate.required;
|
||||
}
|
||||
if (typeof candidate.description === "string") {
|
||||
parsed.description = candidate.description;
|
||||
}
|
||||
if (typeof candidate.url === "string") {
|
||||
parsed.url = candidate.url;
|
||||
}
|
||||
return parsed;
|
||||
})
|
||||
.filter((item): item is MarketplaceEnvVar => item !== null);
|
||||
return env.length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
const urls = [MARKETPLACE_CATALOG_URL];
|
||||
try {
|
||||
const backendEndpoint = await resolveDesktopBackendHttpEndpoint();
|
||||
urls.unshift(new URL(MARKETPLACE_CATALOG_URL, `${backendEndpoint}/`).href);
|
||||
} catch {
|
||||
// Fall back to the statically exported route when the sidecar is unavailable.
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let lastError: unknown;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch marketplace: ${response.status}`);
|
||||
}
|
||||
data = await response.json();
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("Failed to fetch marketplace");
|
||||
}
|
||||
|
||||
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
|
||||
const rawCounts =
|
||||
typeof data?.counts === "object" && data.counts !== null ? data.counts : {};
|
||||
|
||||
const tags: MarketplaceTag[] = Array.isArray(data?.tags)
|
||||
? data.tags
|
||||
.map((tag: unknown) => {
|
||||
if (!tag || typeof tag !== "object") return null;
|
||||
const candidate = tag as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.id !== "string" ||
|
||||
typeof candidate.label !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: candidate.id,
|
||||
label: candidate.label,
|
||||
count: parseCount(candidate.count),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(tag: MarketplaceTag | null): tag is MarketplaceTag => tag !== null,
|
||||
)
|
||||
: [];
|
||||
|
||||
const entries: MarketplaceEntry[] = Array.isArray(data?.entries)
|
||||
? data.entries
|
||||
.map((entry: unknown) => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const install =
|
||||
typeof candidate.install === "object" && candidate.install !== null
|
||||
? (candidate.install as Record<string, unknown>)
|
||||
: {};
|
||||
if (
|
||||
typeof candidate.id !== "string" ||
|
||||
!isPrimitiveType(candidate.type) ||
|
||||
typeof candidate.name !== "string" ||
|
||||
typeof candidate.tagline !== "string" ||
|
||||
typeof candidate.description !== "string" ||
|
||||
typeof install.command !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
name: candidate.name,
|
||||
featured:
|
||||
typeof candidate.featured === "boolean"
|
||||
? candidate.featured
|
||||
: undefined,
|
||||
tagline: candidate.tagline,
|
||||
description: candidate.description,
|
||||
tags: toStringArray(candidate.tags),
|
||||
install: {
|
||||
args: toStringArray(install.args),
|
||||
command: install.command,
|
||||
env: parseEnv(install.env),
|
||||
notes:
|
||||
typeof install.notes === "string" ? install.notes : undefined,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry: MarketplaceEntry | null): entry is MarketplaceEntry =>
|
||||
entry !== null && entry.install.args.length > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
version: parseCount(data?.version) || EMPTY_CATALOG.version,
|
||||
generatedAt:
|
||||
typeof data?.generatedAt === "string" ? data.generatedAt : undefined,
|
||||
baseUrl,
|
||||
counts: {
|
||||
total: parseCount(rawCounts.total) || entries.length,
|
||||
plugins: parseCount(rawCounts.plugins),
|
||||
skills: parseCount(rawCounts.skills),
|
||||
mcps: parseCount(rawCounts.mcps),
|
||||
},
|
||||
tags,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
export { EMPTY_CATALOG, MARKETPLACE_CATALOG_URL };
|
||||
@@ -46,6 +46,7 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -59,6 +59,39 @@ function toStringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool outputs arrive in several shapes depending on the source: a plain
|
||||
* record (live hook events), a JSON-encoded string, or a list of content
|
||||
* blocks such as [{ type: "text", text: "<json>" }] (persisted history).
|
||||
*/
|
||||
function normalizeToolOutput(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return asRecord(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const record = asRecord(entry);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (typeof record.text === "string") {
|
||||
const inner = normalizeToolOutput(record.text);
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return asRecord(value);
|
||||
}
|
||||
|
||||
function getHookEventName(event: SessionHookEvent): string {
|
||||
return event.hookEventName ?? event.hookName ?? "";
|
||||
}
|
||||
@@ -101,7 +134,7 @@ function stripApplyPatchWrapperLines(lines: string[]): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseApplyPatchInput(input: string): SessionFileDiff[] {
|
||||
export function parseApplyPatchInput(input: string): SessionFileDiff[] {
|
||||
const lines = stripApplyPatchWrapperLines(
|
||||
input.split("\n").map((line) => line.replace(/\r$/, "")),
|
||||
);
|
||||
@@ -332,12 +365,20 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
}
|
||||
|
||||
const input = asRecord(event.toolInput);
|
||||
const output = asRecord(event.toolOutput);
|
||||
const output = normalizeToolOutput(event.toolOutput);
|
||||
if (!input || !output || output.success === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = toStringValue(input.command);
|
||||
// Current editor schema has no `command` field; derive the operation from
|
||||
// the input shape (legacy `command` values still take precedence).
|
||||
const command =
|
||||
toStringValue(input.command) ??
|
||||
(input.insert_line != null
|
||||
? "insert"
|
||||
: toStringValue(input.old_text) != null
|
||||
? "str_replace"
|
||||
: "create");
|
||||
const pathFromInput = toStringValue(input.path);
|
||||
const query = toStringValue(output.query);
|
||||
const pathFromQuery = query?.includes(":")
|
||||
@@ -348,10 +389,10 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (command === "str_replace") {
|
||||
const parsed = parseDiffFromEditorResult(
|
||||
toStringValue(output.result) ?? "",
|
||||
);
|
||||
const resultText = toStringValue(output.result) ?? "";
|
||||
|
||||
if (command === "str_replace" && !resultText.startsWith("File created")) {
|
||||
const parsed = parseDiffFromEditorResult(resultText);
|
||||
return {
|
||||
path,
|
||||
additions: parsed.additions,
|
||||
@@ -360,9 +401,12 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "create" || command === "insert") {
|
||||
if (command === "create" || command === "insert" || command === "str_replace") {
|
||||
const newContent =
|
||||
toStringValue(input.file_text) ?? toStringValue(input.new_str) ?? "";
|
||||
toStringValue(input.new_text) ??
|
||||
toStringValue(input.file_text) ??
|
||||
toStringValue(input.new_str) ??
|
||||
"";
|
||||
return {
|
||||
path,
|
||||
additions: countAddedLines(newContent),
|
||||
@@ -392,13 +436,15 @@ function parseApplyPatchFileDiffs(event: SessionHookEvent): SessionFileDiff[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const input = asRecord(event.toolInput);
|
||||
const output = asRecord(event.toolOutput);
|
||||
if (!input || !output || output.success === false) {
|
||||
const output = normalizeToolOutput(event.toolOutput);
|
||||
if (!output || output.success === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patchInput = toStringValue(input.input);
|
||||
// apply_patch accepts either { input: string } or a raw patch string.
|
||||
const patchInput =
|
||||
toStringValue(event.toolInput) ??
|
||||
toStringValue(asRecord(event.toolInput)?.input);
|
||||
if (!patchInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
|
||||
|
||||
export type HubTheme = "light" | "dark";
|
||||
|
||||
export function readStoredHubTheme(): HubTheme | null {
|
||||
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
}
|
||||
|
||||
export function readSystemHubTheme(): HubTheme {
|
||||
const kind = document.body.dataset.vscodeThemeKind;
|
||||
if (kind) {
|
||||
return kind === "vscode-dark" || kind === "vscode-high-contrast"
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export function applyHubTheme(theme: HubTheme): HubTheme {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.dataset.clineHubTheme = theme;
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function syncHubTheme(): HubTheme {
|
||||
return applyHubTheme(readStoredHubTheme() ?? readSystemHubTheme());
|
||||
}
|
||||
|
||||
export function setStoredHubTheme(theme: HubTheme): HubTheme {
|
||||
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
|
||||
return applyHubTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow OS light/dark changes while the user has no stored preference.
|
||||
* Returns a cleanup function that removes the listener.
|
||||
*/
|
||||
export function watchSystemHubTheme(
|
||||
onChange?: (theme: HubTheme) => void,
|
||||
): () => void {
|
||||
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
if (!media) {
|
||||
return () => {};
|
||||
}
|
||||
const handle = () => {
|
||||
if (readStoredHubTheme() !== null) {
|
||||
return;
|
||||
}
|
||||
onChange?.(applyHubTheme(readSystemHubTheme()));
|
||||
};
|
||||
media.addEventListener("change", handle);
|
||||
return () => media.removeEventListener("change", handle);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <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.
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
|
||||
@@ -178,6 +179,39 @@ 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)
|
||||
@@ -187,6 +221,7 @@ async function main() {
|
||||
await extensionCtx.rebuild()
|
||||
await extensionCtx.dispose()
|
||||
}
|
||||
copyPluginSandboxBootstrap()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -467,6 +467,7 @@
|
||||
"@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",
|
||||
@@ -519,6 +520,7 @@
|
||||
"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,23 +10,6 @@ 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,7 +30,6 @@ enum ClineAsk {
|
||||
USE_MCP_SERVER = 11;
|
||||
NEW_TASK = 12;
|
||||
CONDENSE = 13;
|
||||
REPORT_BUG = 14;
|
||||
SUMMARIZE_TASK = 15;
|
||||
ACT_MODE_RESPOND = 16;
|
||||
USE_SUBAGENTS = 17;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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(/^.*[/\\]/, "")
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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,6 +27,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
backgroundCommandTaskId?: string
|
||||
workspaceManager?: any
|
||||
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
|
||||
getPluginSlashCommands?: () => Promise<{ name: string; description?: string }[]>
|
||||
}): Promise<ExtensionState> {
|
||||
const stateManager = controller.stateManager
|
||||
|
||||
@@ -108,6 +109,15 @@ 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,
|
||||
@@ -155,6 +165,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
taskHistory: processedTaskHistory,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
pluginSlashCommands,
|
||||
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
|
||||
backgroundCommandTaskId: controller.backgroundCommandTaskId,
|
||||
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
|
||||
|
||||
@@ -71,6 +71,7 @@ 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"
|
||||
@@ -87,6 +88,7 @@ import {
|
||||
isSyntheticSdkUserMessage,
|
||||
type SdkUserMessage,
|
||||
} from "./sdk-user-message-mapping"
|
||||
import { StatePostDebouncer } from "./state-post-debouncer"
|
||||
import { createTaskProxy, type TaskProxy } from "./task-proxy"
|
||||
import { syncTelemetrySettingFromSharedGlobalSettings } from "./telemetry-settings-sync"
|
||||
import { TurnStateTracker } from "./turn-state-tracker"
|
||||
@@ -165,6 +167,7 @@ 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
|
||||
@@ -172,6 +175,10 @@ export class Controller {
|
||||
private readonly providerConfigStoreSubscription: Disposable
|
||||
private providerConfigStatePostScheduled = false
|
||||
|
||||
// Debounces/coalesces postStateToWebview() calls — see StatePostDebouncer.
|
||||
private static readonly STATE_POST_DEBOUNCE_MS = 50
|
||||
private readonly statePostDebouncer: StatePostDebouncer
|
||||
|
||||
// Bridges SDK events to the webview's gRPC streams.
|
||||
private grpcBridge: WebviewGrpcBridge
|
||||
|
||||
@@ -226,6 +233,10 @@ export class Controller {
|
||||
this.stateManager = StateManager.get()
|
||||
syncTelemetrySettingFromSharedGlobalSettings(this.stateManager)
|
||||
this.sdkTelemetry = createVscodeSdkTelemetryHandle()
|
||||
this.statePostDebouncer = new StatePostDebouncer({
|
||||
debounceMs: Controller.STATE_POST_DEBOUNCE_MS,
|
||||
flush: () => this.flushStateToWebview(),
|
||||
})
|
||||
this.providerConfigStore = createProviderConfigStore()
|
||||
this.providerCatalog = createProviderCatalog(this.providerConfigStore)
|
||||
this.providerConfigStoreSubscription = this.providerConfigStore.subscribe((event) => {
|
||||
@@ -471,6 +482,7 @@ export class Controller {
|
||||
},
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
})
|
||||
this.pluginCommands = new SdkPluginCommandCoordinator()
|
||||
this.taskStart = new SdkTaskStartCoordinator({
|
||||
stateManager: this.stateManager,
|
||||
sessions: this.sessions,
|
||||
@@ -566,6 +578,16 @@ 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()
|
||||
|
||||
@@ -664,7 +686,11 @@ export class Controller {
|
||||
}
|
||||
await this.setRemoteConfigCoreIntegration(undefined)
|
||||
this.isDisposed = true
|
||||
// Tear down the debounced state-post machinery before downstream resources
|
||||
// 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()
|
||||
@@ -725,14 +751,48 @@ export class Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
@@ -1742,7 +1802,25 @@ export class Controller {
|
||||
|
||||
// ---- State management ----
|
||||
|
||||
async postStateToWebview(): Promise<void> {
|
||||
/**
|
||||
* Request a webview state update.
|
||||
*
|
||||
* Callers fire this very frequently (notably the session event coordinator,
|
||||
* once per streamed message/turn boundary), and each rebuild walks the full
|
||||
* task history. StatePostDebouncer coalesces bursts into a single trailing
|
||||
* rebuild to avoid hammering the extension host. The returned promise
|
||||
* resolves once a snapshot reflecting this request has been shipped, or
|
||||
* rejects if that rebuild failed.
|
||||
*/
|
||||
postStateToWebview(): Promise<void> {
|
||||
if (this.isDisposed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return this.statePostDebouncer.post()
|
||||
}
|
||||
|
||||
/** Build the current ExtensionState and push it to the webview immediately. */
|
||||
private async flushStateToWebview(): Promise<void> {
|
||||
// Import dynamically to avoid circular deps
|
||||
const { sendStateUpdate } = await import("@core/controller/state/subscribeToState")
|
||||
const state = await this.getStateToPostToWebview()
|
||||
@@ -1773,6 +1851,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,150 @@ describe("SdkTaskHistory", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("patches cached history record in place without re-listing from host", async () => {
|
||||
const { history, listHistory } = makeHistory([
|
||||
makeSessionRecord("task-1", { updatedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeSessionRecord("task-2", { updatedAt: "2026-01-02T00:00:00.000Z" }),
|
||||
])
|
||||
|
||||
// Populate cache
|
||||
await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Update task-1
|
||||
await history.updateTaskHistoryItem(makeHistoryItem("task-1", { task: "new title" }))
|
||||
|
||||
// Read again — should use cache, not re-list from host
|
||||
const result = await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cached record should reflect the updated prompt and metadata
|
||||
const updated = result.find((r) => r.sessionId === "task-1")
|
||||
expect(updated?.prompt).toBe("new title")
|
||||
expect(updated?.metadata).toEqual(expect.objectContaining({ title: "new title" }))
|
||||
// updatedAt should be bumped, not the original "2026-01-01T00:00:00.000Z"
|
||||
expect(updated?.updatedAt).not.toBe("2026-01-01T00:00:00.000Z")
|
||||
})
|
||||
|
||||
it("re-sorts cached history after patching so the updated record bubbles up", async () => {
|
||||
const { history } = makeHistory([
|
||||
makeSessionRecord("task-1", { updatedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeSessionRecord("task-2", { updatedAt: "2026-01-02T00:00:00.000Z" }),
|
||||
])
|
||||
|
||||
// Populate cache — task-2 (newer) should be first
|
||||
const initial = await history.listHistory({ hydrate: false })
|
||||
expect(initial[0].sessionId).toBe("task-2")
|
||||
expect(initial[1].sessionId).toBe("task-1")
|
||||
|
||||
// Update task-1 with a timestamp newer than task-2
|
||||
await history.updateTaskHistoryItem(
|
||||
makeHistoryItem("task-1", { task: "updated", ts: Date.parse("2026-01-03T00:00:00.000Z") }),
|
||||
)
|
||||
|
||||
// task-1 should now be first due to re-sort
|
||||
const result = await history.listHistory({ hydrate: false })
|
||||
expect(result[0].sessionId).toBe("task-1")
|
||||
})
|
||||
|
||||
it("patches cache in place on per-turn usage updates", async () => {
|
||||
const { history, listHistory } = makeHistory([
|
||||
makeSessionRecord("task-1", {
|
||||
metadata: { tokensIn: 10, tokensOut: 20, totalCost: 0.01 },
|
||||
}),
|
||||
])
|
||||
|
||||
// Populate cache
|
||||
await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Per-turn usage update (the streaming hot path)
|
||||
await history.updateTaskUsage("task-1", {
|
||||
tokensIn: 100,
|
||||
tokensOut: 200,
|
||||
totalCost: 0.03,
|
||||
})
|
||||
|
||||
// Read again — should use cache, not re-list from host
|
||||
const result = await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
const updated = result.find((r) => r.sessionId === "task-1")
|
||||
expect(updated?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
tokensIn: 110,
|
||||
tokensOut: 220,
|
||||
totalCost: 0.04,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("bumps cached updatedAt to the write time, not a stale HistoryItem.ts", async () => {
|
||||
// Simulates toggleTaskFavorite(), which reuses an existing HistoryItem
|
||||
// (with its original, possibly old, `ts`) to flip just `isFavorited`. The
|
||||
// persistence adapter always stamps `updatedAt` with the wall-clock write
|
||||
// time (nowIso()), so the cache patch must do the same rather than
|
||||
// deriving `updatedAt` from the stale `item.ts` — otherwise the cached
|
||||
// ordering would diverge from what's on disk until the TTL expires.
|
||||
const { history } = makeHistory([
|
||||
makeSessionRecord("task-1", { updatedAt: "2020-01-01T00:00:00.000Z" }),
|
||||
makeSessionRecord("task-2", { updatedAt: "2026-01-02T00:00:00.000Z" }),
|
||||
])
|
||||
|
||||
const initial = await history.listHistory({ hydrate: false })
|
||||
expect(initial[0].sessionId).toBe("task-2")
|
||||
|
||||
// Reuse task-1's original (stale) ts, as toggleTaskFavorite() does.
|
||||
await history.updateTaskHistoryItem(makeHistoryItem("task-1", { ts: Date.parse("2020-01-01T00:00:00.000Z") }))
|
||||
|
||||
const result = await history.listHistory({ hydrate: false })
|
||||
const updated = result.find((r) => r.sessionId === "task-1")
|
||||
// updatedAt must reflect the write time, not the stale 2020 timestamp.
|
||||
expect(updated?.updatedAt).not.toBe("2020-01-01T00:00:00.000Z")
|
||||
expect(result[0].sessionId).toBe("task-1")
|
||||
})
|
||||
|
||||
it("invalidates cache when updating a session not present in it", async () => {
|
||||
const { history, listHistory } = makeHistory([makeSessionRecord("task-1")])
|
||||
|
||||
// Populate cache with just task-1
|
||||
await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Update task-2, which is NOT in the cache
|
||||
await history.updateTaskHistoryItem(makeHistoryItem("task-2", { task: "new" }))
|
||||
|
||||
// Next read should re-list from host (cache was invalidated)
|
||||
await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("invalidates rather than patches the cache when the underlying write didn't land", async () => {
|
||||
// host.update() resolves { updated: false } when the session was deleted
|
||||
// out from under the write, or an optimistic-concurrency retry was
|
||||
// exhausted by a racing writer. Patching the cache in that case would show
|
||||
// a fake "updated" record until the TTL expires.
|
||||
const { history, listHistory, updateSession } = makeHistory([
|
||||
makeSessionRecord("task-1", { prompt: "original", updatedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
])
|
||||
|
||||
// Populate cache
|
||||
await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
updateSession.mockResolvedValueOnce({ updated: false })
|
||||
await history.updateTaskHistoryItem(makeHistoryItem("task-1", { task: "should not stick" }))
|
||||
|
||||
// Next read should re-list from host (cache was invalidated, not patched)
|
||||
const result = await history.listHistory({ hydrate: false })
|
||||
expect(listHistory).toHaveBeenCalledTimes(2)
|
||||
// The mock's underlying store was never touched since update() bailed early,
|
||||
// so the re-listed record still shows the original prompt/updatedAt.
|
||||
const record = result.find((r) => r.sessionId === "task-1")
|
||||
expect(record?.prompt).toBe("original")
|
||||
expect(record?.updatedAt).toBe("2026-01-01T00:00:00.000Z")
|
||||
})
|
||||
})
|
||||
|
||||
function makeHistoryItem(id: string, overrides: Partial<HistoryItem> = {}): HistoryItem {
|
||||
|
||||
@@ -67,6 +67,21 @@ function dateStringToTimestamp(value: string | null | undefined): number {
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort comparator for session history records by recency: newest first.
|
||||
*
|
||||
* Falls back through `updatedAt` → `endedAt` → `startedAt` so records that
|
||||
* haven't been touched since creation still sort deterministically. Used both
|
||||
* when merging the initial list and when re-sorting after a single-record
|
||||
* patch, so the two orderings can never diverge.
|
||||
*/
|
||||
function compareSessionHistoryRecordsByRecencyDesc(a: SessionHistoryRecord, b: SessionHistoryRecord): number {
|
||||
return (
|
||||
dateStringToTimestamp(b.updatedAt ?? b.endedAt ?? b.startedAt) -
|
||||
dateStringToTimestamp(a.updatedAt ?? a.endedAt ?? a.startedAt)
|
||||
)
|
||||
}
|
||||
|
||||
function historyItemHasTokenUsage(item: HistoryItem): boolean {
|
||||
return (item.tokensIn ?? 0) > 0 || (item.tokensOut ?? 0) > 0 || (item.cacheReads ?? 0) > 0 || (item.cacheWrites ?? 0) > 0
|
||||
}
|
||||
@@ -382,6 +397,40 @@ export class SdkTaskHistory {
|
||||
this.metadataHistoryCache = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a persistence-layer write into the cache so the next read sees
|
||||
* the updated record without a full re-enumeration.
|
||||
*
|
||||
* The persistence layer bumps `updatedAt` on every write, so the cached
|
||||
* record is updated to match and the cache is re-sorted to preserve the
|
||||
* descending-`updatedAt` ordering that {@link listHistory} establishes.
|
||||
* When the session isn't in the cache (e.g. a brand-new task whose list
|
||||
* membership/ordering may change) the cache is invalidated so the next
|
||||
* read re-enumerates from disk.
|
||||
*/
|
||||
private updateCachedSessionRecord(
|
||||
sessionId: string,
|
||||
updates: { prompt: string; metadata: Record<string, unknown>; updatedAt: string },
|
||||
): void {
|
||||
const cache = this.metadataHistoryCache
|
||||
if (!cache) {
|
||||
return
|
||||
}
|
||||
const index = cache.records.findIndex((record) => record.sessionId === sessionId)
|
||||
if (index === -1) {
|
||||
this.invalidateMetadataHistoryCache()
|
||||
return
|
||||
}
|
||||
const existing = cache.records[index]
|
||||
cache.records[index] = {
|
||||
...existing,
|
||||
prompt: updates.prompt,
|
||||
metadata: updates.metadata,
|
||||
updatedAt: updates.updatedAt,
|
||||
}
|
||||
cache.records.sort(compareSessionHistoryRecordsByRecencyDesc)
|
||||
}
|
||||
|
||||
private canUseMetadataHistoryCache(options: SdkTaskHistoryListOptions): boolean {
|
||||
return options.hydrate === false
|
||||
}
|
||||
@@ -433,11 +482,7 @@ export class SdkTaskHistory {
|
||||
(item) => metadataBoolean(item.metadata, "migratedFromLegacyTask") === true,
|
||||
).length
|
||||
|
||||
const mergedHistory = [...visibleSdkHistory, ...legacyHistory].sort(
|
||||
(a, b) =>
|
||||
dateStringToTimestamp(b.updatedAt ?? b.endedAt ?? b.startedAt) -
|
||||
dateStringToTimestamp(a.updatedAt ?? a.endedAt ?? a.startedAt),
|
||||
)
|
||||
const mergedHistory = [...visibleSdkHistory, ...legacyHistory].sort(compareSessionHistoryRecordsByRecencyDesc)
|
||||
if (useCache) {
|
||||
this.metadataHistoryCache = {
|
||||
records: mergedHistory,
|
||||
@@ -579,7 +624,7 @@ export class SdkTaskHistory {
|
||||
}
|
||||
|
||||
private async updateSession(sessionId: string, item: HistoryItem): Promise<void> {
|
||||
await this.withHistoryHost(async (host) => {
|
||||
const { metadata: writtenMetadata, updated } = await this.withHistoryHost(async (host) => {
|
||||
const existing = await host.get(sessionId)
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(existing?.metadata ?? {}),
|
||||
@@ -593,13 +638,31 @@ export class SdkTaskHistory {
|
||||
delete metadata.size
|
||||
}
|
||||
}
|
||||
await host.update(sessionId, {
|
||||
const result = await host.update(sessionId, {
|
||||
prompt: item.task,
|
||||
metadata,
|
||||
title: item.task,
|
||||
})
|
||||
return { metadata, updated: result.updated }
|
||||
})
|
||||
if (!updated) {
|
||||
// The write didn't land (e.g. the session was deleted, or an optimistic-
|
||||
// concurrency retry was exhausted by a racing writer). Patching the cache
|
||||
// here would show a fake "updated" record until the TTL expires, so
|
||||
// invalidate instead and let the next read re-enumerate from disk.
|
||||
this.invalidateMetadataHistoryCache()
|
||||
return
|
||||
}
|
||||
// The persistence adapter stamps `updatedAt` with the wall-clock write time
|
||||
// (see `nowIso()` in file-session-service.ts), not `item.ts`. Mirror that here
|
||||
// rather than deriving from `item.ts`: callers like toggleTaskFavorite() reuse
|
||||
// an old HistoryItem whose `ts` predates this write, which would otherwise let
|
||||
// the cached ordering diverge from what's on disk until the cache TTL expires.
|
||||
this.updateCachedSessionRecord(sessionId, {
|
||||
prompt: item.task,
|
||||
metadata: writtenMetadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
this.invalidateMetadataHistoryCache()
|
||||
}
|
||||
|
||||
async updateTaskHistoryItem(item: HistoryItem): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { StatePostDebouncer } from "./state-post-debouncer"
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("StatePostDebouncer", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("coalesces bursts of post() calls within the debounce window into a single flush", async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined)
|
||||
const debouncer = new StatePostDebouncer({ debounceMs: 50, flush })
|
||||
|
||||
const p1 = debouncer.post()
|
||||
const p2 = debouncer.post()
|
||||
const p3 = debouncer.post()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("rejects all callers of the batch when flush() fails, instead of swallowing the error", async () => {
|
||||
// Before this fix, runDebouncedStatePost() caught the flush error, logged
|
||||
// it, and resolved every pending caller — so postStateToWebview() callers
|
||||
// could never observe a failed rebuild. Callers must see the rejection.
|
||||
const failure = new Error("boom")
|
||||
const flush = vi.fn().mockRejectedValue(failure)
|
||||
const debouncer = new StatePostDebouncer({ debounceMs: 50, flush })
|
||||
|
||||
const p1 = debouncer.post()
|
||||
const p2 = debouncer.post()
|
||||
// Attach rejection handlers to both promises before awaiting either, so
|
||||
// neither is briefly "unhandled" while the other's assertion runs.
|
||||
const settled = Promise.allSettled([p1, p2])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
const [result1, result2] = await settled
|
||||
expect(result1).toEqual({ status: "rejected", reason: failure })
|
||||
expect(result2).toEqual({ status: "rejected", reason: failure })
|
||||
})
|
||||
|
||||
it("does not let a request that joins an in-flight flush overwrite the tracked in-flight promise", async () => {
|
||||
// Regression test for the P1 dispose race: a second debounced timer that
|
||||
// fires while a flush is already running must fold into the running flush
|
||||
// via `queued` rather than replacing the internal in-flight promise with
|
||||
// a throwaway resolved one. Otherwise dispose() could await the wrong
|
||||
// (already-resolved) promise and tear down resources while the original,
|
||||
// still-running flush is mid-execution.
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
const firstFlushGate = new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
let flushCallCount = 0
|
||||
const flush = vi.fn(async () => {
|
||||
flushCallCount += 1
|
||||
if (flushCallCount === 1) {
|
||||
await firstFlushGate
|
||||
}
|
||||
})
|
||||
const debouncer = new StatePostDebouncer({ debounceMs: 50, flush })
|
||||
|
||||
// First post() starts the debounce timer; once it fires, flush() #1 begins
|
||||
// and blocks on firstFlushGate.
|
||||
const p1 = debouncer.post()
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A second post() arrives while flush #1 is still in flight. Its debounce
|
||||
// timer fires while `inFlight` is still true, so runDebounced() must fold
|
||||
// it into the running loop (via `queued`) rather than starting a second,
|
||||
// independently-tracked flush.
|
||||
const p2 = debouncer.post()
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
// dispose() races the still-running flush #1. If the internal in-flight
|
||||
// promise had been overwritten by the second (queued, no-op) call, this
|
||||
// would resolve well before flush #1 finishes.
|
||||
let disposeResolved = false
|
||||
const disposePromise = debouncer.dispose().then(() => {
|
||||
disposeResolved = true
|
||||
})
|
||||
|
||||
// Drain many microtask ticks (generously more than the couple of hops
|
||||
// needed to unwrap an already-settled promise chain) without resolving
|
||||
// firstFlushGate. dispose() must still be pending — it can only resolve
|
||||
// once the real, still-running flush #1 finishes.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(disposeResolved).toBe(false)
|
||||
|
||||
resolveFirstFlush?.()
|
||||
await disposePromise
|
||||
expect(disposeResolved).toBe(true)
|
||||
|
||||
// The queued second flush runs once flush #1 completes and dispose() has
|
||||
// not yet forced an early loop exit.
|
||||
await p1
|
||||
await p2.catch(() => {})
|
||||
})
|
||||
|
||||
it("post() resolves immediately without shipping state after dispose", async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined)
|
||||
const debouncer = new StatePostDebouncer({ debounceMs: 50, flush })
|
||||
|
||||
await debouncer.dispose()
|
||||
await debouncer.post()
|
||||
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
// Coalesces frequent postStateToWebview() requests into a single trailing
|
||||
// rebuild. During a streaming turn the session event coordinator can fire
|
||||
// postStateToWebview() many times per second; each call rebuilds the full
|
||||
// ExtensionState (including task history), which is expensive enough to
|
||||
// saturate the extension host event loop if run on every event. This class
|
||||
// owns the debounce timer, in-flight/queued bookkeeping, and the resolver
|
||||
// list, extracted from SdkController so the concurrency behavior can be unit
|
||||
// tested in isolation.
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export interface StatePostDebouncerOptions {
|
||||
/** Trailing debounce window: bursts of post() calls within this window collapse into one flush. */
|
||||
debounceMs: number
|
||||
/** Builds and ships the current state snapshot. Rejections propagate to post() callers. */
|
||||
flush: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce/coalesce state posts.
|
||||
*
|
||||
* `post()` resolves once a snapshot reflecting that call has been shipped (or
|
||||
* rejects if the flush that shipped it failed — errors are not swallowed, so
|
||||
* callers awaiting `post()` can tell a state update did not reach the
|
||||
* webview). Requests arriving while a flush is in flight are folded into
|
||||
* `queued`; exactly one more flush runs afterward so the final snapshot is
|
||||
* never stale.
|
||||
*/
|
||||
export class StatePostDebouncer {
|
||||
private debounceTimer?: NodeJS.Timeout
|
||||
private inFlight = false
|
||||
private inFlightPromise?: Promise<void>
|
||||
private queued = false
|
||||
private pendingResolvers: Array<{ resolve: () => void; reject: (error: unknown) => void }> = []
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: StatePostDebouncerOptions) {}
|
||||
|
||||
post(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.pendingResolvers.push({ resolve, reject })
|
||||
if (this.debounceTimer) {
|
||||
return
|
||||
}
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = undefined
|
||||
// If a flush loop is already running, runDebounced() just folds this
|
||||
// request into it (via `queued`) and returns a throwaway resolved
|
||||
// promise without doing any work. Only track the promise from the
|
||||
// call that actually starts the flush loop — otherwise that trivial
|
||||
// promise would overwrite the reference to the real, still-running
|
||||
// flush, and dispose() could await the wrong one and return while
|
||||
// the original flush is still executing.
|
||||
const isStartingNewFlush = !this.inFlight
|
||||
const runPromise = this.runDebounced()
|
||||
if (isStartingNewFlush) {
|
||||
this.inFlightPromise = runPromise
|
||||
}
|
||||
}, this.options.debounceMs)
|
||||
this.debounceTimer.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
private async runDebounced(): Promise<void> {
|
||||
if (this.inFlight) {
|
||||
this.queued = true
|
||||
return
|
||||
}
|
||||
this.inFlight = true
|
||||
try {
|
||||
do {
|
||||
this.queued = false
|
||||
const resolvers = this.pendingResolvers
|
||||
this.pendingResolvers = []
|
||||
try {
|
||||
await this.options.flush()
|
||||
for (const { resolve } of resolvers) {
|
||||
resolve()
|
||||
}
|
||||
} catch (error) {
|
||||
// Preserve rejection semantics: callers awaiting post() must see
|
||||
// the failure, not a silent success, so command handlers don't
|
||||
// assume the webview received a fresh snapshot when it didn't.
|
||||
Logger.error("[StatePostDebouncer] Failed to post state to webview:", error)
|
||||
for (const { reject } of resolvers) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
} while (this.queued && !this.disposed)
|
||||
} finally {
|
||||
this.inFlight = false
|
||||
this.inFlightPromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the debounce machinery: cancel any pending timer and settle
|
||||
* in-flight awaiters so callers blocked on `post()` don't hang past
|
||||
* disposal. Awaits any flush that's still executing so it either completes
|
||||
* or bails via the `disposed` guard before the caller tears down downstream
|
||||
* resources.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = undefined
|
||||
}
|
||||
this.queued = false
|
||||
const pendingResolvers = this.pendingResolvers
|
||||
this.pendingResolvers = []
|
||||
for (const { resolve } of pendingResolvers) {
|
||||
resolve()
|
||||
}
|
||||
const inFlight = this.inFlightPromise
|
||||
if (inFlight) {
|
||||
await inFlight.catch(() => {})
|
||||
this.inFlightPromise = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "reportbug", or custom workflow name)
|
||||
* @param commandName The name of the command (e.g., "newtask", "newrule", 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,6 +16,7 @@ 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
|
||||
@@ -114,6 +115,8 @@ 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
|
||||
@@ -211,7 +214,6 @@ export type ClineAsk =
|
||||
| "new_task"
|
||||
| "condense"
|
||||
| "summarize_task"
|
||||
| "report_bug"
|
||||
| "use_subagents"
|
||||
|
||||
export type ClineSay =
|
||||
|
||||
@@ -24,7 +24,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -57,7 +56,6 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ export interface SlashCommand {
|
||||
name: string
|
||||
description?: string
|
||||
section?: "default" | "custom" | "mcp"
|
||||
cliCompatible?: boolean
|
||||
}
|
||||
|
||||
export const BASE_SLASH_COMMANDS: SlashCommand[] = [
|
||||
@@ -10,31 +9,21 @@ 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,7 +27,6 @@ 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",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,285 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -780,15 +780,6 @@ 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,7 +55,6 @@ 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"
|
||||
@@ -1159,16 +1158,6 @@ 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,6 +224,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteConfigSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
} = useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
@@ -489,6 +490,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
|
||||
if (allCommands.length === 0) {
|
||||
@@ -514,6 +516,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
if (commands.length > 0) {
|
||||
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
|
||||
@@ -673,6 +676,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
slashCommandsQuery,
|
||||
handleSlashCommandsSelect,
|
||||
sendingDisabled,
|
||||
pluginSlashCommands,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -984,6 +988,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
|
||||
if (isValidCommand) {
|
||||
@@ -997,7 +1003,14 @@ 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])
|
||||
}, [
|
||||
localWorkflowToggles,
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHighlights()
|
||||
@@ -1400,6 +1413,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
mcpServers={mcpServers}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
onSelect={handleSlashCommandsSelect}
|
||||
pluginSlashCommands={pluginSlashCommands}
|
||||
query={slashCommandsQuery}
|
||||
remoteWorkflows={remoteConfigSettings?.remoteGlobalWorkflows}
|
||||
remoteWorkflowToggles={remoteWorkflowToggles}
|
||||
|
||||
@@ -37,9 +37,6 @@ const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: "You can drag and drop images into the chat to share screenshots with Cline.",
|
||||
},
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
{
|
||||
text: 'You can disable these tips in Settings → Features → "Feature Tips".',
|
||||
},
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from "react"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
|
||||
interface ReportBugPreviewProps {
|
||||
data: string
|
||||
}
|
||||
|
||||
const ReportBugPreview: React.FC<ReportBugPreviewProps> = ({ data }) => {
|
||||
// Parse the JSON data from the context string
|
||||
const bugData = React.useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(data || "{}")
|
||||
} catch (e) {
|
||||
console.error("Failed to parse bug report data", e)
|
||||
return {}
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="bg-badge-background/50 text-badge-foreground rounded-xs p-3">
|
||||
<h2 className="font-bold mb-3">{bugData.title || "Bug Report"}</h2>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
{bugData.what_happened && (
|
||||
<div>
|
||||
<div className="font-semibold">What Happened?</div>
|
||||
<MarkdownBlock markdown={bugData.what_happened} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.steps_to_reproduce && (
|
||||
<div>
|
||||
<div className="font-semibold">Steps to Reproduce</div>
|
||||
<MarkdownBlock markdown={bugData.steps_to_reproduce} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.api_request_output && (
|
||||
<div>
|
||||
<div className="font-semibold">Relevant API Request Output</div>
|
||||
<MarkdownBlock markdown={bugData.api_request_output} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.provider_and_model && (
|
||||
<div>
|
||||
<div className="font-semibold">Provider/Model</div>
|
||||
<MarkdownBlock markdown={bugData.provider_and_model} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.operating_system && (
|
||||
<div>
|
||||
<div className="font-semibold">Operating System</div>
|
||||
<MarkdownBlock markdown={bugData.operating_system} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.system_info && (
|
||||
<div>
|
||||
<div className="font-semibold">System Info</div>
|
||||
<MarkdownBlock markdown={bugData.system_info} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.cline_version && (
|
||||
<div>
|
||||
<div className="font-semibold">Cline Version</div>
|
||||
<MarkdownBlock markdown={bugData.cline_version} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bugData.additional_context && (
|
||||
<div>
|
||||
<div className="font-semibold">Additional Context</div>
|
||||
<MarkdownBlock markdown={bugData.additional_context} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportBugPreview
|
||||
@@ -16,6 +16,7 @@ interface SlashCommandMenuProps {
|
||||
remoteWorkflowToggles?: Record<string, boolean>
|
||||
remoteWorkflows?: any[]
|
||||
mcpServers?: McpServer[]
|
||||
pluginSlashCommands?: SlashCommand[]
|
||||
}
|
||||
|
||||
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
@@ -29,6 +30,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
mcpServers = [],
|
||||
pluginSlashCommands = [],
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -40,6 +42,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
mcpServers,
|
||||
pluginSlashCommands,
|
||||
)
|
||||
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
|
||||
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock("@/services/grpc-client", () => ({
|
||||
},
|
||||
SlashServiceClient: {
|
||||
condense: (req: unknown) => condense(req),
|
||||
reportBug: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
UiServiceClient: {
|
||||
trackIntent: (req: unknown) => trackIntent(req),
|
||||
|
||||
@@ -179,8 +179,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
case "mistake_limit_reached":
|
||||
case "api_req_failed":
|
||||
case "new_task":
|
||||
case "condense":
|
||||
case "report_bug": {
|
||||
case "condense": {
|
||||
// Most askResponse sends need a temporary webview-only user bubble because the
|
||||
// extension will not echo the user's message until later. Active follow-up
|
||||
// questions are the exception: they are backed by the SDK's pending ask_question
|
||||
@@ -411,11 +410,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
console.error(err),
|
||||
)
|
||||
break
|
||||
case "report_bug":
|
||||
await SlashServiceClient.reportBug(StringRequest.create({ value: lastMessage?.text })).catch((err) =>
|
||||
console.error(err),
|
||||
)
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ describe("getButtonConfig", () => {
|
||||
{ ask: "resume_completed_task", expectedConfig: "resume_completed_task" },
|
||||
{ ask: "new_task", expectedConfig: "new_task" },
|
||||
{ ask: "condense", expectedConfig: "condense" },
|
||||
{ ask: "report_bug", expectedConfig: "report_bug" },
|
||||
]
|
||||
|
||||
stateConfigs.forEach(({ ask, expectedConfig }) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ButtonActionType =
|
||||
| "proceed" // Send messageResponse or yesButtonClicked
|
||||
| "new_task" // Start a new task
|
||||
| "cancel" // Cancel streaming
|
||||
| "utility" // Execute utility function (condense, report_bug)
|
||||
| "utility" // Execute utility function (condense)
|
||||
| "retry" // Retry the last action
|
||||
|
||||
/**
|
||||
@@ -169,15 +169,6 @@ export const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
primaryAction: "utility",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
report_bug: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Report GitHub issue",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "utility",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Streaming/partial states - disable interaction during streaming
|
||||
partial: {
|
||||
sendingDisabled: true,
|
||||
@@ -287,8 +278,6 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode =
|
||||
// Utility
|
||||
case "condense":
|
||||
return BUTTON_CONFIGS.condense
|
||||
case "report_bug":
|
||||
return BUTTON_CONFIGS.report_bug
|
||||
|
||||
default:
|
||||
return BUTTON_CONFIGS.tool_approve
|
||||
|
||||
@@ -289,6 +289,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
localAgentsRulesToggles: {},
|
||||
localWorkflowToggles: {},
|
||||
globalWorkflowToggles: {},
|
||||
pluginSlashCommands: [],
|
||||
shellIntegrationTimeout: 4000,
|
||||
terminalReuseEnabled: true,
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
@@ -906,6 +907,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
localAgentsRulesToggles: state.localAgentsRulesToggles || {},
|
||||
localWorkflowToggles: state.localWorkflowToggles || {},
|
||||
globalWorkflowToggles: state.globalWorkflowToggles || {},
|
||||
pluginSlashCommands: state.pluginSlashCommands || [],
|
||||
remoteRulesToggles: state.remoteRulesToggles || {},
|
||||
remoteWorkflowToggles: state.remoteWorkflowToggles || {},
|
||||
enableCheckpointsSetting: state.enableCheckpointsSetting,
|
||||
|
||||
@@ -181,6 +181,7 @@ export function getMatchingSlashCommands(
|
||||
remoteWorkflowToggles?: Record<string, boolean>,
|
||||
remoteWorkflows?: any[],
|
||||
mcpServers: McpServer[] = [],
|
||||
pluginSlashCommands: SlashCommand[] = [],
|
||||
): SlashCommand[] {
|
||||
const workflowCommands = getWorkflowCommands(
|
||||
localWorkflowToggles,
|
||||
@@ -189,7 +190,7 @@ export function getMatchingSlashCommands(
|
||||
remoteWorkflows,
|
||||
)
|
||||
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands, ...pluginSlashCommands]
|
||||
|
||||
if (!query) {
|
||||
return allCommands
|
||||
@@ -233,6 +234,7 @@ export function validateSlashCommand(
|
||||
remoteWorkflowToggles?: Record<string, boolean>,
|
||||
remoteWorkflows?: any[],
|
||||
mcpServers: McpServer[] = [],
|
||||
pluginSlashCommands: SlashCommand[] = [],
|
||||
): "full" | "partial" | null {
|
||||
if (!command) {
|
||||
return null
|
||||
@@ -245,7 +247,7 @@ export function validateSlashCommand(
|
||||
remoteWorkflows,
|
||||
)
|
||||
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands, ...pluginSlashCommands]
|
||||
|
||||
// case insensitive matching
|
||||
const exactMatch = allCommands.some((cmd) => cmd.name.toLowerCase() === command.toLowerCase())
|
||||
|
||||
@@ -19,8 +19,6 @@ Type `/` in the chat input to see available slash commands:
|
||||
| `/newtask` | Start fresh task with distilled context from current conversation |
|
||||
| `/smol` | Compress conversation history while preserving essential context |
|
||||
| `/newrule` | Create a rule file to teach Cline your preferences |
|
||||
| `/deep-planning` | Investigate codebase, plan thoroughly, then create implementation task |
|
||||
| `/reportbug` | Report a bug with diagnostic info |
|
||||
|
||||
### /newtask
|
||||
|
||||
@@ -40,23 +38,6 @@ Use `/smol` when you're deep into a debugging session or brainstorming and need
|
||||
|
||||
Use `/newrule` when you find yourself repeating the same instructions across tasks. For more about rules, see [Cline Rules](/customization/cline-rules).
|
||||
|
||||
### /deep-planning
|
||||
|
||||
Transform Cline into a meticulous architect who investigates your codebase, asks clarifying questions, and creates a comprehensive implementation plan before writing any code. Deep planning follows a four-step process:
|
||||
|
||||
1. **Silent Investigation** - Cline explores your codebase structure and patterns
|
||||
2. **Discussion** - Targeted questions about requirements and approach
|
||||
3. **Plan Creation** - Generates `implementation_plan.md` with detailed specifications
|
||||
4. **Task Creation** - Creates a new task with trackable implementation steps
|
||||
|
||||
Use `/deep-planning` for features touching multiple parts of your codebase, architectural changes, or complex integrations.
|
||||
|
||||
### /reportbug
|
||||
|
||||
`/reportbug` collects diagnostic information and helps you report issues with Cline. It gathers relevant context like your configuration, recent errors, and system details to make bug reports more useful for the development team.
|
||||
|
||||
Use `/reportbug` when you encounter unexpected behavior, crashes, or bugs you want to report.
|
||||
|
||||
## Skills via Slash Commands
|
||||
|
||||
In addition to built-in commands, you can trigger enabled skills directly from chat using slash commands.
|
||||
|
||||
+1
-1
@@ -258,5 +258,5 @@ These same patterns work for any project, from simple scripts to full applicatio
|
||||
## Need Help?
|
||||
|
||||
- **Start a fresh conversation**: Type `/new` in the chat input to begin a new task
|
||||
- **Report issues**: Use `/reportbug` to help us improve
|
||||
- **Report issues**: Open an issue at [github.com/cline/cline/issues](https://github.com/cline/cline/issues)
|
||||
- **Get support**: Join our [Discord community](https://discord.gg/cline)
|
||||
|
||||
@@ -39,17 +39,18 @@ import type {
|
||||
PendingPromptsServiceApi,
|
||||
RuntimeHost,
|
||||
RuntimeHostSubscribeOptions,
|
||||
SessionConnectionRuntimeService,
|
||||
SessionModelRuntimeService,
|
||||
SessionUsageRuntimeService,
|
||||
StartSessionInput,
|
||||
StartSessionResult,
|
||||
} from "./runtime/host/runtime-host";
|
||||
import { compareCheckpointToWorkspace } from "./session/checkpoint-diff";
|
||||
import {
|
||||
FeatureFlagsService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
} from "./services/feature-flags";
|
||||
import { resolveCoreDistinctId } from "./services/telemetry/distinct-id";
|
||||
import { compareCheckpointToWorkspace } from "./session/checkpoint-diff";
|
||||
import type { CoreSessionEvent } from "./types/events";
|
||||
import type { SessionHistoryRecord } from "./types/sessions";
|
||||
|
||||
@@ -69,10 +70,10 @@ export type {
|
||||
ClineCoreOptions,
|
||||
ClineCoreSettingsApi,
|
||||
ClineCoreStartInput,
|
||||
HubOptions,
|
||||
RemoteOptions,
|
||||
CompareCheckpointInput,
|
||||
CompareCheckpointResult,
|
||||
HubOptions,
|
||||
RemoteOptions,
|
||||
RestoreInput,
|
||||
RestoreOptions,
|
||||
RestoreResult,
|
||||
@@ -620,4 +621,13 @@ export class ClineCore {
|
||||
const service = this.host as RuntimeHostServiceExtensions;
|
||||
return service.updateSessionModel?.(...args) ?? Promise.resolve();
|
||||
};
|
||||
/**
|
||||
* Updates provider/model/reasoning connection options for subsequent turns in
|
||||
* an active session.
|
||||
*/
|
||||
updateSessionConnection: SessionConnectionRuntimeService["updateSessionConnection"] =
|
||||
(...args) => {
|
||||
const service = this.host as RuntimeHostServiceExtensions;
|
||||
return service.updateSessionConnection?.(...args) ?? Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
PendingPromptsRuntimeService,
|
||||
PendingPromptsServiceApi,
|
||||
RuntimeHost,
|
||||
SessionConnectionRuntimeService,
|
||||
SessionModelRuntimeService,
|
||||
SessionUsageRuntimeService,
|
||||
} from "../runtime/host/runtime-host";
|
||||
@@ -27,6 +28,7 @@ export type RuntimeHostServiceExtensions = RuntimeHost &
|
||||
Partial<
|
||||
PendingPromptsRuntimeService &
|
||||
SessionUsageRuntimeService &
|
||||
SessionConnectionRuntimeService &
|
||||
SessionModelRuntimeService
|
||||
>;
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
export type {
|
||||
BlockBudgetClass,
|
||||
BudgetAction,
|
||||
BudgetActionKind,
|
||||
BudgetActionReason,
|
||||
BudgetPath,
|
||||
BudgetPolicyIntent,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
ContentBlockBudgetClassification,
|
||||
LiveTailHandling,
|
||||
} from "./types";
|
||||
@@ -1,476 +0,0 @@
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
|
||||
const estimateChars = (message: MessageWithMetadata) =>
|
||||
JSON.stringify(message).length;
|
||||
|
||||
describe("buildBudgetProjection", () => {
|
||||
it("fails explicitly for impossible budgets", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [{ role: "user", content: "keep me" }],
|
||||
targetTokens: 0,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.warnings[0]?.code).toBe("budget_impossible");
|
||||
});
|
||||
|
||||
it("drops unsafe image and redacted thinking blocks instead of truncating them", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "old context" },
|
||||
{
|
||||
type: "redacted_thinking",
|
||||
data: "x".repeat(500),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: "y".repeat(500),
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 150,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).not.toContain("redacted_thinking");
|
||||
expect(serialized).not.toContain("image/png");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.liveTailHandling).toBe("included_degraded");
|
||||
});
|
||||
|
||||
it("keeps unsafe blocks when input is already under budget", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look at this" },
|
||||
{
|
||||
type: "image",
|
||||
data: "small-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.actions).toEqual([]);
|
||||
expect(result.liveTailHandling).toBe("included_verbatim");
|
||||
expect(JSON.stringify(result.messages)).toContain("small-image");
|
||||
});
|
||||
|
||||
it("preserves unsafe blocks in the latest typed user message", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
data: "live-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 120,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result.messages)).toContain("live-image");
|
||||
expect(result.actions).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "dropped_block" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("protects latest typed user after thinking-only messages are pruned", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "discard me" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
data: "live-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("live-image");
|
||||
expect(serialized).not.toContain("discard me");
|
||||
});
|
||||
|
||||
it("keeps tool-use and tool-result pairs coherent when dropping history", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "original task" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "read_files",
|
||||
input: { file_paths: ["/tmp/a.ts"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read_files",
|
||||
content: "x".repeat(1000),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 140,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).not.toContain("tool_1");
|
||||
expect(serialized).toContain("latest task");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "tool_pair_boundary" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("records budget action paths against original message indexes", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "x", mediaType: "image/png" }],
|
||||
},
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "assistant", content: "old answer " + "y".repeat(500) },
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 80,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "preserved",
|
||||
path: expect.objectContaining({ messageIndex: 1 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
path: expect.objectContaining({ messageIndex: 2 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects the latest typed user message when tool results follow it", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "old task" },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_1", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read",
|
||||
content: "result",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findLatestTypedUserMessageIndex(messages)).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the latest typed prompt under pressure", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read",
|
||||
content: "result " + "y".repeat(500),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 120,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result.messages)).toContain("latest typed prompt");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "protected_live_tail" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops completed tool pairs after the latest typed prompt", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_after", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_after",
|
||||
name: "read",
|
||||
content: "huge result " + "y".repeat(2_000),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 140,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("latest typed prompt");
|
||||
expect(serialized).not.toContain("tool_after");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
reason: "tool_pair_boundary",
|
||||
path: expect.objectContaining({ messageIndex: 2 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
reason: "tool_pair_boundary",
|
||||
path: expect.objectContaining({ messageIndex: 3 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves unresolved tool use after the latest typed prompt", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_live",
|
||||
name: "run_command",
|
||||
input: { command: "sleep 1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 80,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("latest typed prompt");
|
||||
expect(serialized).toContain("tool_live");
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.warnings[0]?.code).toBe(
|
||||
"budget_unachievable_with_protections",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not preserve later text or file blocks after tool-result budget is exhausted", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_live", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_live",
|
||||
name: "read",
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(200) },
|
||||
{ type: "file", path: "/tmp/huge.txt", content: "b".repeat(1_000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 260,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("latest typed prompt");
|
||||
expect(serialized).not.toContain("b".repeat(100));
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "truncated_text",
|
||||
reason: "over_budget",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops thinking blocks instead of mutating provider-native reasoning", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(1_000) },
|
||||
{ type: "thinking", thinking: "b".repeat(1_000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 900,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const assistant = result.messages.find(
|
||||
(message) => message.role === "assistant",
|
||||
);
|
||||
expect(JSON.stringify(assistant)).not.toContain("b".repeat(100));
|
||||
expect(JSON.stringify(assistant)).not.toContain("\"thinking\"");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops nested unsafe tool-result blocks outside the protected tail", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_old",
|
||||
name: "read",
|
||||
content: [
|
||||
{ type: "text", text: "old output" },
|
||||
{
|
||||
type: "image",
|
||||
data: "old-image-data",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("old output");
|
||||
expect(serialized).not.toContain("old-image-data");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,670 +0,0 @@
|
||||
import type {
|
||||
ContentBlock,
|
||||
MessageWithMetadata,
|
||||
ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
BudgetAction,
|
||||
BudgetMutationAction,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
BudgetPolicyIntent,
|
||||
} from "./types";
|
||||
|
||||
type EstimateMessageTokens = (message: MessageWithMetadata) => number;
|
||||
|
||||
interface ProjectionPolicy {
|
||||
protectLatestTypedUser: boolean;
|
||||
protectLiveTailFromDrop: boolean;
|
||||
dropUnsafeOutsideLiveTail: boolean;
|
||||
dropThinkingBlocks: boolean;
|
||||
}
|
||||
|
||||
function resolveProjectionPolicy(
|
||||
intent: BudgetPolicyIntent,
|
||||
): ProjectionPolicy {
|
||||
switch (intent) {
|
||||
case "agentic_summary":
|
||||
case "basic_compaction_projection":
|
||||
return {
|
||||
protectLatestTypedUser: true,
|
||||
protectLiveTailFromDrop: true,
|
||||
dropUnsafeOutsideLiveTail: true,
|
||||
dropThinkingBlocks: true,
|
||||
};
|
||||
case "normal_provider_request":
|
||||
return {
|
||||
protectLatestTypedUser: true,
|
||||
protectLiveTailFromDrop: true,
|
||||
dropUnsafeOutsideLiveTail: false,
|
||||
dropThinkingBlocks: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function cloneMessages(messages: MessageWithMetadata[]): MessageWithMetadata[] {
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
content: Array.isArray(message.content)
|
||||
? message.content.map((block) => ({ ...block }) as ContentBlock)
|
||||
: message.content,
|
||||
...(message.metadata ? { metadata: { ...message.metadata } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function safeJsonSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
} catch {
|
||||
return String(value).length;
|
||||
}
|
||||
}
|
||||
|
||||
function totalTokens(
|
||||
messages: MessageWithMetadata[],
|
||||
estimateMessageTokens: EstimateMessageTokens,
|
||||
): number {
|
||||
return messages.reduce(
|
||||
(total, message) => total + estimateMessageTokens(message),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function isToolResultOnlyUserMessage(message: MessageWithMetadata): boolean {
|
||||
return (
|
||||
message.role === "user" &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.length > 0 &&
|
||||
message.content.every((block) => block.type === "tool_result")
|
||||
);
|
||||
}
|
||||
|
||||
export function findLatestTypedUserMessageIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findFirstTypedUserMessageIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): number {
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collectToolIds(message: MessageWithMetadata): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!Array.isArray(message.content)) {
|
||||
return ids;
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use") {
|
||||
ids.add(block.id);
|
||||
} else if (block.type === "tool_result") {
|
||||
ids.add(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function buildToolPairIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): Map<string, Set<number>> {
|
||||
const index = new Map<string, Set<number>>();
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
for (const id of collectToolIds(messages[messageIndex])) {
|
||||
const existing = index.get(id);
|
||||
if (existing) {
|
||||
existing.add(messageIndex);
|
||||
} else {
|
||||
index.set(id, new Set([messageIndex]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function findProtectedTailStartIndex(messages: MessageWithMetadata[]): number {
|
||||
const resolvedToolUseIds = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_result") {
|
||||
resolvedToolUseIds.add(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
message.content.some(
|
||||
(block) =>
|
||||
block.type === "tool_use" && !resolvedToolUseIds.has(block.id),
|
||||
)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return messages.length;
|
||||
}
|
||||
|
||||
function collectMessageClosure(
|
||||
messages: MessageWithMetadata[],
|
||||
startIndex: number,
|
||||
): Set<number> {
|
||||
const pairIndex = buildToolPairIndex(messages);
|
||||
const removal = new Set<number>();
|
||||
const queue = [startIndex];
|
||||
while (queue.length > 0) {
|
||||
const index = queue.shift();
|
||||
if (index === undefined || removal.has(index)) {
|
||||
continue;
|
||||
}
|
||||
removal.add(index);
|
||||
for (const id of collectToolIds(messages[index])) {
|
||||
for (const linked of pairIndex.get(id) ?? []) {
|
||||
if (!removal.has(linked)) {
|
||||
queue.push(linked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removal;
|
||||
}
|
||||
|
||||
function isUnsafeBlock(block: ContentBlock): boolean {
|
||||
return block.type === "image" || block.type === "redacted_thinking";
|
||||
}
|
||||
|
||||
function isNestedUnsafeToolResultBlock(
|
||||
block: Extract<ToolResultContent["content"], unknown[]>[number],
|
||||
): boolean {
|
||||
return block.type === "image";
|
||||
}
|
||||
|
||||
function shouldDropWholeBlock(
|
||||
block: ContentBlock,
|
||||
policy: ProjectionPolicy,
|
||||
isProtected: boolean,
|
||||
): boolean {
|
||||
if (policy.dropThinkingBlocks && block.type === "thinking") {
|
||||
return true;
|
||||
}
|
||||
return policy.dropUnsafeOutsideLiveTail && !isProtected && isUnsafeBlock(block);
|
||||
}
|
||||
|
||||
function pruneEmptyMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
reason: BudgetMutationAction["reason"] = "over_budget",
|
||||
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
|
||||
const next: MessageWithMetadata[] = [];
|
||||
const nextOriginalIndexes: number[] = [];
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
if (Array.isArray(message.content) && message.content.length === 0) {
|
||||
actions.push({
|
||||
kind: "dropped_message",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason,
|
||||
originalSize: safeJsonSize(message),
|
||||
finalSize: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push(message);
|
||||
nextOriginalIndexes.push(originalIndexes[index]);
|
||||
}
|
||||
return { messages: next, originalIndexes: nextOriginalIndexes };
|
||||
}
|
||||
|
||||
function dropUnsafeBlocks(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
latestTypedUserIndex: number,
|
||||
protectedTailStartIndex: number,
|
||||
policy: ProjectionPolicy,
|
||||
): MessageWithMetadata[] {
|
||||
return messages.map((message, messageIndex) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
let changed = false;
|
||||
const protectedBlock =
|
||||
messageIndex === latestTypedUserIndex ||
|
||||
messageIndex >= protectedTailStartIndex;
|
||||
const content = message.content.flatMap((block, blockIndex) => {
|
||||
if (shouldDropWholeBlock(block, policy, protectedBlock)) {
|
||||
changed = true;
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: 0,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
if (block.type === "tool_result" && Array.isArray(block.content)) {
|
||||
const nestedContent = block.content.filter((nestedBlock) => {
|
||||
if (
|
||||
policy.dropUnsafeOutsideLiveTail &&
|
||||
!protectedBlock &&
|
||||
isNestedUnsafeToolResultBlock(nestedBlock)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (nestedContent.length !== block.content.length) {
|
||||
changed = true;
|
||||
const nextBlock = { ...block, content: nestedContent };
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: safeJsonSize(nextBlock),
|
||||
});
|
||||
return [nextBlock];
|
||||
}
|
||||
}
|
||||
return [block];
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
});
|
||||
}
|
||||
|
||||
function dropThinkingBlocks(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
): MessageWithMetadata[] {
|
||||
return messages.map((message, messageIndex) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
let changed = false;
|
||||
const content = message.content.filter((block, blockIndex) => {
|
||||
if (block.type !== "thinking") {
|
||||
return true;
|
||||
}
|
||||
changed = true;
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: 0,
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function truncateText(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
if (maxChars <= 16) {
|
||||
return text.slice(0, Math.max(1, maxChars));
|
||||
}
|
||||
const estimateMarker = `\n...[truncated ${text.length - maxChars} chars]`;
|
||||
const keep = Math.max(1, maxChars - estimateMarker.length);
|
||||
const marker = `\n...[truncated ${text.length - keep} chars]`;
|
||||
return `${text.slice(0, keep)}${marker}`;
|
||||
}
|
||||
|
||||
function truncateToolResultContent(
|
||||
content: ToolResultContent["content"],
|
||||
maxChars: number,
|
||||
): ToolResultContent["content"] {
|
||||
if (typeof content === "string") {
|
||||
return truncateText(content, maxChars);
|
||||
}
|
||||
let remaining = maxChars;
|
||||
return content.map((block) => {
|
||||
if (remaining <= 0) {
|
||||
if (block.type === "text") {
|
||||
return { ...block, text: "" };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return { ...block, content: "" };
|
||||
}
|
||||
return block;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
const text = truncateText(block.text, remaining);
|
||||
remaining -= text.length;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
const content = truncateText(block.content, remaining);
|
||||
remaining -= content.length;
|
||||
return { ...block, content };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
||||
function toolResultTextLength(content: ToolResultContent["content"]): number {
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
return content.reduce((total, block) => {
|
||||
if (block.type === "text") {
|
||||
return total + block.text.length;
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return total + block.content.length;
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function truncateMessageText(
|
||||
message: MessageWithMetadata,
|
||||
maxChars: number,
|
||||
): MessageWithMetadata {
|
||||
if (typeof message.content === "string") {
|
||||
return { ...message, content: truncateText(message.content, maxChars) };
|
||||
}
|
||||
let remaining = maxChars;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((block) => {
|
||||
if (remaining <= 0) {
|
||||
if (block.type === "text") {
|
||||
return { ...block, text: "" };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return { ...block, content: "" };
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
return {
|
||||
...block,
|
||||
content: truncateToolResultContent(block.content, 0),
|
||||
};
|
||||
}
|
||||
return block;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
const text = truncateText(block.text, remaining);
|
||||
remaining -= text.length;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
const content = truncateText(block.content, remaining);
|
||||
remaining -= content.length;
|
||||
return { ...block, content };
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
const content = truncateToolResultContent(block.content, remaining);
|
||||
remaining -= toolResultTextLength(content);
|
||||
return { ...block, content };
|
||||
}
|
||||
return block;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function hasTruncatableText(message: MessageWithMetadata): boolean {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.length > 0;
|
||||
}
|
||||
return message.content.some(
|
||||
(block) =>
|
||||
block.type === "text" ||
|
||||
block.type === "file" ||
|
||||
block.type === "tool_result",
|
||||
);
|
||||
}
|
||||
|
||||
function removeMessagesAt(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
removal: Set<number>,
|
||||
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
|
||||
return {
|
||||
messages: messages.filter((_, index) => !removal.has(index)),
|
||||
originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)),
|
||||
};
|
||||
}
|
||||
|
||||
function closureTouchesProtectedTail(
|
||||
closure: Set<number>,
|
||||
protectedStartIndex: number,
|
||||
): boolean {
|
||||
if (protectedStartIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
for (const removalIndex of closure) {
|
||||
if (removalIndex >= protectedStartIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function closureTouchesPinnedMessage(
|
||||
closure: Set<number>,
|
||||
pinnedIndex: number,
|
||||
): boolean {
|
||||
return pinnedIndex >= 0 && closure.has(pinnedIndex);
|
||||
}
|
||||
|
||||
export function buildBudgetProjection(
|
||||
options: BudgetProjectionOptions,
|
||||
): BudgetProjectionResult {
|
||||
const actions: BudgetAction[] = [];
|
||||
const warnings: BudgetProjectionWarning[] = [];
|
||||
const policy = resolveProjectionPolicy(options.policyIntent);
|
||||
if (options.targetTokens <= 0) {
|
||||
return {
|
||||
status: "failed",
|
||||
messages: cloneMessages(options.messages),
|
||||
actions,
|
||||
liveTailHandling: "preserved_out_of_band",
|
||||
estimatedTokens: totalTokens(
|
||||
options.messages,
|
||||
options.estimateMessageTokens,
|
||||
),
|
||||
warnings: [
|
||||
{
|
||||
code: "budget_impossible",
|
||||
message: "Target budget must be greater than zero.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let messages = cloneMessages(options.messages);
|
||||
let originalIndexes = messages.map((_, index) => index);
|
||||
if (policy.dropThinkingBlocks) {
|
||||
const prunedThinking = pruneEmptyMessages(
|
||||
dropThinkingBlocks(messages, originalIndexes, actions),
|
||||
originalIndexes,
|
||||
actions,
|
||||
"unsafe_to_truncate",
|
||||
);
|
||||
messages = prunedThinking.messages;
|
||||
originalIndexes = prunedThinking.originalIndexes;
|
||||
}
|
||||
const latestTypedUserIndex = policy.protectLatestTypedUser
|
||||
? findLatestTypedUserMessageIndex(messages)
|
||||
: -1;
|
||||
const protectedTailStartIndex = policy.protectLiveTailFromDrop
|
||||
? findProtectedTailStartIndex(messages)
|
||||
: messages.length;
|
||||
if (policy.dropUnsafeOutsideLiveTail) {
|
||||
const prunedUnsafe = pruneEmptyMessages(
|
||||
dropUnsafeBlocks(
|
||||
messages,
|
||||
originalIndexes,
|
||||
actions,
|
||||
latestTypedUserIndex,
|
||||
protectedTailStartIndex,
|
||||
policy,
|
||||
),
|
||||
originalIndexes,
|
||||
actions,
|
||||
);
|
||||
messages = prunedUnsafe.messages;
|
||||
originalIndexes = prunedUnsafe.originalIndexes;
|
||||
}
|
||||
let estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
if (estimatedTokens <= options.targetTokens) {
|
||||
return {
|
||||
status: "ok",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling:
|
||||
actions.length > 0 ? "included_degraded" : "included_verbatim",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
for (
|
||||
let index = messages.length - 1;
|
||||
index >= 0 && estimatedTokens > options.targetTokens;
|
||||
index -= 1
|
||||
) {
|
||||
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
|
||||
if (index === latestTypedUserIndex) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
policy.protectLiveTailFromDrop &&
|
||||
index >= findProtectedTailStartIndex(messages)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!hasTruncatableText(messages[index])) {
|
||||
continue;
|
||||
}
|
||||
const originalSize = safeJsonSize(messages[index]);
|
||||
const charsPerToken = Math.max(
|
||||
1,
|
||||
originalSize /
|
||||
Math.max(1, options.estimateMessageTokens(messages[index])),
|
||||
);
|
||||
const targetChars = Math.max(
|
||||
16,
|
||||
Math.floor(
|
||||
(options.targetTokens * charsPerToken) /
|
||||
Math.max(1, messages.length),
|
||||
),
|
||||
);
|
||||
messages[index] = truncateMessageText(messages[index], targetChars);
|
||||
actions.push({
|
||||
kind: "truncated_text",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "over_budget",
|
||||
originalSize,
|
||||
finalSize: safeJsonSize(messages[index]),
|
||||
});
|
||||
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
}
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < messages.length && estimatedTokens > options.targetTokens;
|
||||
) {
|
||||
const firstTypedUserIndex = findFirstTypedUserMessageIndex(messages);
|
||||
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
|
||||
const protectedStartIndex = policy.protectLiveTailFromDrop
|
||||
? findProtectedTailStartIndex(messages)
|
||||
: messages.length;
|
||||
if (index === firstTypedUserIndex || index === latestTypedUserIndex) {
|
||||
actions.push({
|
||||
kind: "preserved",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "protected_live_tail",
|
||||
originalSize: safeJsonSize(messages[index]),
|
||||
finalSize: safeJsonSize(messages[index]),
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const closure = collectMessageClosure(messages, index);
|
||||
if (closureTouchesPinnedMessage(closure, firstTypedUserIndex)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (closureTouchesPinnedMessage(closure, latestTypedUserIndex)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (closureTouchesProtectedTail(closure, protectedStartIndex)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
for (const removalIndex of closure) {
|
||||
actions.push({
|
||||
kind: "dropped_message",
|
||||
path: { messageIndex: originalIndexes[removalIndex] },
|
||||
reason:
|
||||
closure.size > 1 || collectToolIds(messages[removalIndex]).size > 0
|
||||
? "tool_pair_boundary"
|
||||
: "over_budget",
|
||||
originalSize: safeJsonSize(messages[removalIndex]),
|
||||
finalSize: 0,
|
||||
});
|
||||
}
|
||||
const removed = removeMessagesAt(messages, originalIndexes, closure);
|
||||
messages = removed.messages;
|
||||
originalIndexes = removed.originalIndexes;
|
||||
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
}
|
||||
|
||||
if (estimatedTokens > options.targetTokens) {
|
||||
warnings.push({
|
||||
code: "budget_unachievable_with_protections",
|
||||
message:
|
||||
"Projection could not reach budget without violating protected content.",
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling: "included_degraded",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling:
|
||||
actions.length > 0 ? "included_degraded" : "included_verbatim",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { ContentBlock, MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
export type BudgetPolicyIntent =
|
||||
| "agentic_summary"
|
||||
| "basic_compaction_projection"
|
||||
| "normal_provider_request";
|
||||
|
||||
export type BudgetActionKind =
|
||||
| "truncated_text"
|
||||
| "dropped_block"
|
||||
| "dropped_message"
|
||||
| "preserved";
|
||||
|
||||
export type BudgetActionReason =
|
||||
| "over_budget"
|
||||
| "unsafe_to_truncate"
|
||||
| "tool_pair_boundary"
|
||||
| "protected_live_tail";
|
||||
|
||||
export type LiveTailHandling =
|
||||
| "included_verbatim"
|
||||
| "included_degraded"
|
||||
| "summarized_as_context"
|
||||
| "omitted_with_warning"
|
||||
| "preserved_out_of_band";
|
||||
|
||||
export type BlockBudgetClass =
|
||||
| "text"
|
||||
| "thinking"
|
||||
| "tool_use"
|
||||
| "tool_result"
|
||||
| "unsafe_binary"
|
||||
| "unsafe_encrypted"
|
||||
| "opaque";
|
||||
|
||||
export interface BudgetPath {
|
||||
messageIndex: number;
|
||||
blockIndex?: number;
|
||||
}
|
||||
|
||||
interface BaseBudgetAction {
|
||||
path: BudgetPath;
|
||||
originalSize: number;
|
||||
finalSize: number;
|
||||
}
|
||||
|
||||
export type BudgetMutationAction =
|
||||
| (BaseBudgetAction & {
|
||||
kind: "truncated_text";
|
||||
reason: Extract<BudgetActionReason, "over_budget">;
|
||||
})
|
||||
| (BaseBudgetAction & {
|
||||
kind: "dropped_block";
|
||||
path: Required<BudgetPath>;
|
||||
reason: Exclude<BudgetActionReason, "protected_live_tail">;
|
||||
})
|
||||
| (BaseBudgetAction & {
|
||||
kind: "dropped_message";
|
||||
reason: Exclude<BudgetActionReason, "protected_live_tail">;
|
||||
});
|
||||
|
||||
export interface BudgetPreservedAction extends BaseBudgetAction {
|
||||
kind: "preserved";
|
||||
reason: Extract<
|
||||
BudgetActionReason,
|
||||
"protected_live_tail" | "tool_pair_boundary"
|
||||
>;
|
||||
}
|
||||
|
||||
export type BudgetAction = BudgetMutationAction | BudgetPreservedAction;
|
||||
|
||||
export type BudgetProjectionWarningCode =
|
||||
| "budget_impossible"
|
||||
| "budget_unachievable_with_protections";
|
||||
|
||||
export interface BudgetProjectionWarning {
|
||||
code: BudgetProjectionWarningCode;
|
||||
message: string;
|
||||
path?: BudgetPath;
|
||||
}
|
||||
|
||||
export interface BudgetProjectionOptions {
|
||||
messages: MessageWithMetadata[];
|
||||
targetTokens: number;
|
||||
policyIntent: BudgetPolicyIntent;
|
||||
estimateMessageTokens: (message: MessageWithMetadata) => number;
|
||||
}
|
||||
|
||||
export interface BudgetProjectionResult {
|
||||
status: "ok" | "failed";
|
||||
messages: MessageWithMetadata[];
|
||||
actions: BudgetAction[];
|
||||
liveTailHandling: LiveTailHandling;
|
||||
estimatedTokens: number;
|
||||
warnings: BudgetProjectionWarning[];
|
||||
}
|
||||
|
||||
export interface ContentBlockBudgetClassification {
|
||||
block: ContentBlock;
|
||||
budgetClass: BlockBudgetClass;
|
||||
canStringTruncate: boolean;
|
||||
canDropWholeBlock: boolean;
|
||||
}
|
||||
@@ -4,6 +4,26 @@ import * as path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEditorExecutor } from "./editor";
|
||||
|
||||
const context = {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
async function withTempFile(
|
||||
content: string,
|
||||
run: (filePath: string, dir: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
|
||||
const filePath = path.join(dir, "example.txt");
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
try {
|
||||
await run(filePath, dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("createEditorExecutor", () => {
|
||||
it("creates a missing file when edit is used", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
|
||||
@@ -80,6 +100,99 @@ describe("createEditorExecutor", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a minimal diff for an in-place single-line edit", async () => {
|
||||
await withTempFile("a\nb\nc", async (filePath, dir) => {
|
||||
const editor = createEditorExecutor();
|
||||
const result = await editor(
|
||||
{ path: filePath, old_text: "b", new_text: "B" },
|
||||
dir,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
`Edited ${filePath}\n\`\`\`diff\n-2: b\n+2: B\n\`\`\``,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("only emits the changed region when the edit changes the line count", async () => {
|
||||
await withTempFile("a\nb\nc\nd\ne\nf", async (filePath, dir) => {
|
||||
const editor = createEditorExecutor();
|
||||
const result = await editor(
|
||||
{ path: filePath, old_text: "b\nc\nd", new_text: "B" },
|
||||
dir,
|
||||
context,
|
||||
);
|
||||
|
||||
// The trailing unchanged lines (e, f) must not be mispaired into
|
||||
// the diff even though their positions shifted.
|
||||
expect(result).toBe(
|
||||
`Edited ${filePath}\n\`\`\`diff\n-2: b\n-3: c\n-4: d\n+2: B\n\`\`\``,
|
||||
);
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(
|
||||
"a\nB\ne\nf",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("emits only additions for a pure insertion via str_replace", async () => {
|
||||
await withTempFile("a\nb\nc", async (filePath, dir) => {
|
||||
const editor = createEditorExecutor();
|
||||
const result = await editor(
|
||||
{ path: filePath, old_text: "a\nb", new_text: "a\nnew\nb" },
|
||||
dir,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
`Edited ${filePath}\n\`\`\`diff\n+2: new\n\`\`\``,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates long diffs at maxDiffLines while keeping both sides visible", async () => {
|
||||
const oldLines = Array.from({ length: 10 }, (_, i) => `old-${i}`);
|
||||
await withTempFile(oldLines.join("\n"), async (filePath, dir) => {
|
||||
const editor = createEditorExecutor({ maxDiffLines: 3 });
|
||||
const result = await editor(
|
||||
{
|
||||
path: filePath,
|
||||
old_text: oldLines.join("\n"),
|
||||
new_text: "replaced",
|
||||
},
|
||||
dir,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
`Edited ${filePath}\n\`\`\`diff\n-1: old-0\n-2: old-1\n+1: replaced\n... diff truncated (8 more removed, 0 more added lines) ...\n\`\`\``,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not drop additions when removals alone exhaust maxDiffLines", async () => {
|
||||
const oldLines = Array.from({ length: 6 }, (_, i) => `old-${i}`);
|
||||
const newLines = Array.from({ length: 4 }, (_, i) => `new-${i}`);
|
||||
await withTempFile(oldLines.join("\n"), async (filePath, dir) => {
|
||||
const editor = createEditorExecutor({ maxDiffLines: 6 });
|
||||
const result = await editor(
|
||||
{
|
||||
path: filePath,
|
||||
old_text: oldLines.join("\n"),
|
||||
new_text: newLines.join("\n"),
|
||||
},
|
||||
dir,
|
||||
context,
|
||||
);
|
||||
|
||||
// Budget splits 3/3 instead of removals consuming all 6 lines and
|
||||
// reporting +0 additions.
|
||||
expect(result).toBe(
|
||||
`Edited ${filePath}\n\`\`\`diff\n-1: old-0\n-2: old-1\n-3: old-2\n+1: new-0\n+2: new-1\n+3: new-2\n... diff truncated (3 more removed, 1 more added lines) ...\n\`\`\``,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects insert_line 0 with the valid one-based boundary range", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
|
||||
const filePath = path.join(dir, "example.txt");
|
||||
|
||||
@@ -71,32 +71,57 @@ function createLineDiff(
|
||||
): string {
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const max = Math.max(oldLines.length, newLines.length);
|
||||
|
||||
// Trim the common prefix and suffix so only the changed region is emitted;
|
||||
// a naive positional compare would mispair every line after an edit that
|
||||
// changes the line count.
|
||||
let start = 0;
|
||||
while (
|
||||
start < oldLines.length &&
|
||||
start < newLines.length &&
|
||||
oldLines[start] === newLines[start]
|
||||
) {
|
||||
start++;
|
||||
}
|
||||
let oldEnd = oldLines.length;
|
||||
let newEnd = newLines.length;
|
||||
while (
|
||||
oldEnd > start &&
|
||||
newEnd > start &&
|
||||
oldLines[oldEnd - 1] === newLines[newEnd - 1]
|
||||
) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
|
||||
// Split the line budget between removals and additions so neither side is
|
||||
// silently dropped when the other alone would exhaust maxLines.
|
||||
const removedCount = oldEnd - start;
|
||||
const addedCount = newEnd - start;
|
||||
let removedBudget = removedCount;
|
||||
let addedBudget = addedCount;
|
||||
if (removedCount + addedCount > maxLines) {
|
||||
removedBudget = Math.min(
|
||||
removedCount,
|
||||
Math.max(Math.ceil(maxLines / 2), maxLines - addedCount),
|
||||
);
|
||||
addedBudget = Math.min(addedCount, maxLines - removedBudget);
|
||||
}
|
||||
|
||||
const out: string[] = ["```diff"];
|
||||
let emitted = 0;
|
||||
for (let i = start; i < start + removedBudget; i++) {
|
||||
out.push(`-${i + 1}: ${oldLines[i]}`);
|
||||
}
|
||||
for (let i = start; i < start + addedBudget; i++) {
|
||||
out.push(`+${i + 1}: ${newLines[i]}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
if (emitted >= maxLines) {
|
||||
out.push("... diff truncated ...");
|
||||
break;
|
||||
}
|
||||
|
||||
const oldLine = oldLines[i];
|
||||
const newLine = newLines[i];
|
||||
|
||||
if (oldLine === newLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineNo = i + 1;
|
||||
if (oldLine !== undefined) {
|
||||
out.push(`-${lineNo}: ${oldLine}`);
|
||||
emitted++;
|
||||
}
|
||||
if (newLine !== undefined && emitted < maxLines) {
|
||||
out.push(`+${lineNo}: ${newLine}`);
|
||||
emitted++;
|
||||
}
|
||||
const omittedRemoved = removedCount - removedBudget;
|
||||
const omittedAdded = addedCount - addedBudget;
|
||||
if (omittedRemoved > 0 || omittedAdded > 0) {
|
||||
out.push(
|
||||
`... diff truncated (${omittedRemoved} more removed, ${omittedAdded} more added lines) ...`,
|
||||
);
|
||||
}
|
||||
|
||||
out.push("```");
|
||||
|
||||
@@ -27,6 +27,8 @@ export type DelegatedAgentConnectionConfig = Pick<
|
||||
| "providerConfig"
|
||||
| "knownModels"
|
||||
| "thinking"
|
||||
| "reasoningEffort"
|
||||
| "thinkingBudgetTokens"
|
||||
| "maxTokensPerTurn"
|
||||
>;
|
||||
|
||||
@@ -88,6 +90,8 @@ export function createDelegatedAgentConfigProvider(
|
||||
providerConfig: runtimeConfig.providerConfig,
|
||||
knownModels: runtimeConfig.knownModels,
|
||||
thinking: runtimeConfig.thinking,
|
||||
reasoningEffort: runtimeConfig.reasoningEffort,
|
||||
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
|
||||
maxTokensPerTurn: runtimeConfig.maxTokensPerTurn,
|
||||
}),
|
||||
updateConnectionDefaults: (overrides) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user