mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8333e1e86b | ||
|
|
25fa84c9e6 | ||
|
|
da35f08f03 | ||
|
|
02bc1bc432 | ||
|
|
49c0d1b6a3 | ||
|
|
92dc5dfed3 | ||
|
|
ce85e49c7b | ||
|
|
dfecadbcbd | ||
|
|
519a22c5d5 | ||
|
|
fbdfa77bb9 | ||
|
|
5ad8d33977 | ||
|
|
5226b107ba | ||
|
|
26f015fbd1 | ||
|
|
bdce31deea | ||
|
|
406674d27f | ||
|
|
24303ab0cb | ||
|
|
f48ba92357 | ||
|
|
3693d2f867 | ||
|
|
86aca36d03 |
@@ -85,10 +85,3 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(383_616);
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
@@ -104,9 +103,7 @@ export async function handleDesktopCommand(
|
||||
): Promise<unknown> {
|
||||
if (command === "list_provider_catalog") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
return await listLocalProviders(providerSettingsManager);
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
@@ -168,11 +165,6 @@ export async function handleDesktopCommand(
|
||||
providerId,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(providerSettingsManager, providerId, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
@@ -100,9 +99,7 @@ export async function sendProviderCatalog(
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const payload = await listLocalProviders(providerSettingsManager);
|
||||
ctx.send(peer, {
|
||||
type: "provider_catalog",
|
||||
providers: payload.providers,
|
||||
@@ -141,11 +138,6 @@ export async function runProviderOAuthLogin(
|
||||
normalized,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== normalized) {
|
||||
markLocalProviderEnabled(providerSettingsManager, normalized, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
providerId: normalized,
|
||||
|
||||
@@ -258,8 +258,8 @@ export function SettingsView({
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -386,7 +386,7 @@ export function SettingsView({
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
usesOAuth(selectedProvider)
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -13,51 +13,8 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Shareable Desktop Packages
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
- macOS: `bun run package:desktop:mac`
|
||||
- Windows: `bun run package:desktop:windows`
|
||||
- Linux: `bun run package:desktop:linux`
|
||||
|
||||
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
|
||||
|
||||
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
|
||||
|
||||
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
|
||||
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
|
||||
|
||||
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
|
||||
|
||||
### macOS signing & notarization, step by step
|
||||
|
||||
One-time keychain setup:
|
||||
|
||||
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
|
||||
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
|
||||
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
|
||||
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
|
||||
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
|
||||
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
|
||||
|
||||
Per-build:
|
||||
|
||||
```bash
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
|
||||
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
|
||||
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
|
||||
export APPLE_API_ISSUER="<issuer UUID>"
|
||||
bun run package:desktop:mac
|
||||
```
|
||||
|
||||
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
|
||||
|
||||
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
Startup flow:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
@@ -10,11 +10,6 @@
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type DesktopPlatform = "mac" | "windows" | "linux";
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
);
|
||||
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
|
||||
|
||||
process.chdir(APP_ROOT);
|
||||
|
||||
const validateArgs = (): void => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`missing value for ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
|
||||
throw new Error(
|
||||
suggestion
|
||||
? `unknown option ${arg}. Did you mean ${suggestion}?`
|
||||
: `unknown option ${arg}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argument ${arg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getArgValue = (name: string): string | undefined => {
|
||||
const prefix = `${name}=`;
|
||||
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) {
|
||||
return inline.slice(prefix.length);
|
||||
}
|
||||
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index >= 0) {
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasArg = (name: string): boolean => process.argv.includes(name);
|
||||
|
||||
const hostPlatform = (): DesktopPlatform => {
|
||||
if (process.platform === "darwin") {
|
||||
return "mac";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "windows";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return "linux";
|
||||
}
|
||||
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
|
||||
};
|
||||
|
||||
const resolveRequestedPlatform = (): DesktopPlatform => {
|
||||
const platform =
|
||||
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
|
||||
if (platform === "current") {
|
||||
return hostPlatform();
|
||||
}
|
||||
if (platform === "mac" || platform === "windows" || platform === "linux") {
|
||||
return platform;
|
||||
}
|
||||
throw new Error(
|
||||
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeName = (value: string): string =>
|
||||
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
|
||||
|
||||
const packageVersion = async (): Promise<string> => {
|
||||
const packageJson = await Bun.file(
|
||||
path.join(APP_ROOT, "package.json"),
|
||||
).json();
|
||||
return String(packageJson.version ?? "0.0.0");
|
||||
};
|
||||
|
||||
const macDistributionCredentialsConfigured = (): boolean => {
|
||||
const hasCertificate = Boolean(
|
||||
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
|
||||
);
|
||||
const hasAppleIdNotarization = Boolean(
|
||||
process.env.APPLE_ID &&
|
||||
process.env.APPLE_PASSWORD &&
|
||||
process.env.APPLE_TEAM_ID,
|
||||
);
|
||||
const hasApiKeyNotarization = Boolean(
|
||||
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
|
||||
process.env.APPLE_API_KEY_ID &&
|
||||
process.env.APPLE_API_ISSUER,
|
||||
);
|
||||
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
|
||||
};
|
||||
|
||||
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
|
||||
const host = hostPlatform();
|
||||
if (platform !== host) {
|
||||
throw new Error(
|
||||
[
|
||||
`cannot build ${platform} desktop bundles from ${host}.`,
|
||||
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
|
||||
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
|
||||
if (hostPlatform() !== "mac") {
|
||||
return;
|
||||
}
|
||||
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
|
||||
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
|
||||
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
|
||||
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
|
||||
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
|
||||
const walkFiles = (root: string): string[] => {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = path.join(root, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
paths.push(...walkFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
paths.push(fullPath);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const copyArtifact = (source: string, outputName: string): string => {
|
||||
const destination = path.join(PACKAGE_ROOT, outputName);
|
||||
rmSync(destination, { force: true, recursive: true });
|
||||
cpSync(source, destination, { recursive: true });
|
||||
return destination;
|
||||
};
|
||||
|
||||
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --force --deep --sign - ${appPath}`;
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const verifySignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`spctl --assess --type execute --verbose ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const collectMacArtifacts = async (
|
||||
version: string,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
|
||||
if (!existsSync(appPath)) {
|
||||
throw new Error(`macOS app bundle was not created at ${appPath}`);
|
||||
}
|
||||
|
||||
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
|
||||
console.warn(
|
||||
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
|
||||
);
|
||||
await signUnsignedMacApp(appPath);
|
||||
} else {
|
||||
await verifySignedMacApp(appPath);
|
||||
}
|
||||
|
||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
const suffix =
|
||||
allowUnsignedMac && !macDistributionCredentialsConfigured()
|
||||
? "-local-unsigned"
|
||||
: "";
|
||||
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
|
||||
const zipPath = path.join(PACKAGE_ROOT, zipName);
|
||||
rmSync(zipPath, { force: true });
|
||||
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
|
||||
|
||||
const artifacts = [zipPath];
|
||||
if (!suffix) {
|
||||
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
|
||||
(file) => file.endsWith(".dmg"),
|
||||
)) {
|
||||
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
};
|
||||
|
||||
const collectWindowsArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectLinuxArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.endsWith(".AppImage") ||
|
||||
file.endsWith(".deb") ||
|
||||
file.endsWith(".rpm"),
|
||||
)
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectArtifacts = async (
|
||||
platform: DesktopPlatform,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const version = await packageVersion();
|
||||
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
|
||||
mkdirSync(PACKAGE_ROOT, { recursive: true });
|
||||
|
||||
if (platform === "mac") {
|
||||
return collectMacArtifacts(version, allowUnsignedMac);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsArtifacts();
|
||||
}
|
||||
return collectLinuxArtifacts();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
validateArgs();
|
||||
|
||||
const platform = resolveRequestedPlatform();
|
||||
const allowUnsignedMac =
|
||||
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
|
||||
const skipBuild = hasArg("--skip-build");
|
||||
|
||||
assertCanBuildPlatform(platform);
|
||||
if (platform === "mac") {
|
||||
assertMacDistributionReady(allowUnsignedMac);
|
||||
}
|
||||
|
||||
if (!skipBuild) {
|
||||
await $`bun run build:binary`;
|
||||
}
|
||||
|
||||
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(
|
||||
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Packaged ${platform} desktop artifacts:`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionConnectionUpdate } from "./chat-session";
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinking")).toBe(false);
|
||||
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears reasoning settings when thinking is explicitly disabled", () => {
|
||||
expect(
|
||||
buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
reasoningEffort: null,
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates explicit reasoning settings without clearing omitted settings", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,6 @@ import type {
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -107,40 +103,7 @@ function isoTimestampToMs(
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readReasoningEffort(
|
||||
value: unknown,
|
||||
): "low" | "medium" | "high" | "xhigh" | undefined {
|
||||
if (
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return {
|
||||
sessionId: config.sessionId ?? config.session_id,
|
||||
providerId: config.provider ?? config.providerId ?? "",
|
||||
@@ -162,9 +125,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
config.enableAgentTeams ??
|
||||
config.enable_teams ??
|
||||
false,
|
||||
...(thinking !== undefined ? { thinking } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
teamName: config.teamName ?? config.team_name,
|
||||
missionLogIntervalSteps:
|
||||
config.missionStepInterval ?? config.missionLogIntervalSteps,
|
||||
@@ -176,63 +136,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
const updates: SessionConnectionUpdate = {};
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
if (providerId) {
|
||||
updates.providerId = providerId;
|
||||
}
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
if (modelId) {
|
||||
updates.modelId = modelId;
|
||||
}
|
||||
const apiKey =
|
||||
typeof config.apiKey === "string"
|
||||
? config.apiKey.trim()
|
||||
: typeof config.api_key === "string"
|
||||
? config.api_key.trim()
|
||||
: undefined;
|
||||
if (apiKey) {
|
||||
updates.apiKey = apiKey;
|
||||
}
|
||||
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
|
||||
updates.baseUrl = config.baseUrl.trim();
|
||||
}
|
||||
if (config.headers && typeof config.headers === "object") {
|
||||
updates.headers = config.headers as Record<string, string>;
|
||||
}
|
||||
if (config.providerConfig && typeof config.providerConfig === "object") {
|
||||
updates.providerConfig =
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"];
|
||||
}
|
||||
if (thinking === false) {
|
||||
updates.thinking = false;
|
||||
updates.reasoningEffort = null;
|
||||
updates.thinkingBudgetTokens = null;
|
||||
return updates;
|
||||
}
|
||||
if (thinking === true) {
|
||||
updates.thinking = true;
|
||||
}
|
||||
if (reasoningEffort) {
|
||||
updates.thinking = true;
|
||||
updates.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
if (thinkingBudgetTokens !== undefined) {
|
||||
updates.thinking = true;
|
||||
updates.thinkingBudgetTokens = thinkingBudgetTokens;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -460,13 +363,6 @@ async function handleSend(
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective delivery mode.
|
||||
// When the session is busy and no explicit delivery was requested, queue it
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -36,26 +35,13 @@ import {
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
findArtifactUnderDir,
|
||||
readSessionManifest,
|
||||
@@ -959,7 +945,7 @@ export async function handleCommand(
|
||||
if (command === "list_provider_catalog") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
await ensureCustomProvidersLoaded(manager);
|
||||
return await listLocalProviders(manager, { isClinePassEnabled: true });
|
||||
return await listLocalProviders(manager);
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -1039,45 +1025,12 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Global settings ────────────────────────────────────────────────
|
||||
if (command === "get_global_settings") {
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_telemetry_opt_out") {
|
||||
if (typeof args?.telemetry_opt_out !== "boolean") {
|
||||
throw new Error("telemetry_opt_out must be a boolean");
|
||||
}
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return await startConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
if (command === "stop_connector_channel") {
|
||||
return await stopConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
|
||||
// ── MCP server management ─────────────────────────────────────────
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
@@ -1203,26 +1156,6 @@ export async function handleCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(ctx.workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) {
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type WebviewConnectorChannelsResponse = {
|
||||
available: WebviewConnectorChannel[];
|
||||
active: ReturnType<typeof listActiveConnectors>;
|
||||
};
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath =
|
||||
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
type: platform.type,
|
||||
hint: platform.hint,
|
||||
fields: platform.fields.map((field) => ({
|
||||
flag: field.flag,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
prompt: platform.security.prompt,
|
||||
fields: platform.security.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
requiredMessage: field.requiredMessage,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { available, active: listActiveConnectors() };
|
||||
}
|
||||
|
||||
async function runCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
): Promise<{
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve(exitCode ?? 0));
|
||||
});
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function waitForConnectorState(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`connector did not reach expected state within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(platform.id)) {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
() =>
|
||||
!listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import type { SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -17,18 +14,7 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
@@ -53,20 +39,8 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -83,15 +57,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -102,20 +67,11 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
@@ -192,8 +148,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
|
||||
@@ -5,13 +5,10 @@ import { dirname } from "node:path";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type CoreSessionEvent,
|
||||
NodeHubClient,
|
||||
resolveHubOwnerContext,
|
||||
type RuntimeCapabilities,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -389,7 +386,6 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
@@ -434,12 +430,6 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -692,17 +682,10 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(`code-sidecar:${process.pid}:${randomUUID()}`),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
backendMode: "hub",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -720,7 +703,6 @@ export async function initializeSessionManager(
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
@@ -734,6 +716,5 @@ export async function initializeSessionManager(
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
@@ -1,998 +0,0 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir as osHomedir, platform } from "node:os";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
|
||||
|
||||
type MarketplaceEnvVar = {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallInput = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name?: string;
|
||||
install: {
|
||||
args?: string[];
|
||||
env?: MarketplaceEnvVar[];
|
||||
command?: string;
|
||||
notes?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type MarketplaceInstallResult = {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
status: "installed" | "uninstalled";
|
||||
message: string;
|
||||
details?: JsonRecord;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallStatusResult = {
|
||||
installedKeys: string[];
|
||||
};
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type SpawnCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
) => Promise<SpawnResult>;
|
||||
type CatalogFetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
type CatalogLoader = () => Promise<unknown>;
|
||||
|
||||
const MAX_OUTPUT_CHARS = 12_000;
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
|
||||
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
|
||||
const MARKETPLACE_CATALOG_URL =
|
||||
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
const SECRET_PATTERN =
|
||||
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
|
||||
const SECRET_KEY_VALUE_PATTERN =
|
||||
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
|
||||
const SECRET_BEARER_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
|
||||
const SECRET_AUTHORIZATION_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
|
||||
|
||||
export async function fetchMarketplaceCatalog(
|
||||
fetchImpl: CatalogFetch = fetch,
|
||||
): Promise<unknown> {
|
||||
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
|
||||
return value === "mcp" || value === "skill" || value === "plugin";
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function readInstallInput(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput {
|
||||
const entry = readInstallRecord(args);
|
||||
const install =
|
||||
entry.install && typeof entry.install === "object"
|
||||
? (entry.install as Record<string, unknown>)
|
||||
: {};
|
||||
const installArgs = toStringArray(install.args);
|
||||
if (installArgs.length === 0) {
|
||||
throw new Error("marketplace install args are required");
|
||||
}
|
||||
const env = Array.isArray(install.env)
|
||||
? install.env
|
||||
.map((item): MarketplaceEnvVar | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
if (typeof candidate.name !== "string") return null;
|
||||
const parsed: MarketplaceEnvVar = {
|
||||
name: candidate.name,
|
||||
};
|
||||
if (typeof candidate.required === "boolean") {
|
||||
parsed.required = candidate.required;
|
||||
}
|
||||
if (typeof candidate.description === "string") {
|
||||
parsed.description = candidate.description;
|
||||
}
|
||||
if (typeof candidate.url === "string") {
|
||||
parsed.url = candidate.url;
|
||||
}
|
||||
return parsed;
|
||||
})
|
||||
.filter((item): item is MarketplaceEnvVar => item !== null)
|
||||
: undefined;
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
install: {
|
||||
args: installArgs,
|
||||
command:
|
||||
typeof install.command === "string" ? install.command : undefined,
|
||||
env,
|
||||
notes: typeof install.notes === "string" ? install.notes : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRecord(
|
||||
args?: Record<string, unknown>,
|
||||
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
|
||||
const entry =
|
||||
args?.entry && typeof args.entry === "object"
|
||||
? (args.entry as Record<string, unknown>)
|
||||
: (args ?? {});
|
||||
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
|
||||
throw new Error("marketplace entry id is required");
|
||||
}
|
||||
if (!isPrimitiveType(entry.type)) {
|
||||
throw new Error("marketplace entry type must be mcp, skill, or plugin");
|
||||
}
|
||||
return entry as Record<string, unknown> & {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRequest(args?: Record<string, unknown>) {
|
||||
const entry = readInstallRecord(args);
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
};
|
||||
}
|
||||
|
||||
function readLocalUninstallInput(args?: Record<string, unknown>): {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
name?: string;
|
||||
path?: string;
|
||||
} {
|
||||
const type = typeof args?.type === "string" ? args.type.trim() : "";
|
||||
if (
|
||||
type !== "mcp" &&
|
||||
type !== "skill" &&
|
||||
type !== "workflow" &&
|
||||
type !== "plugin"
|
||||
) {
|
||||
throw new Error(
|
||||
"local uninstall type must be mcp, skill, workflow, or plugin",
|
||||
);
|
||||
}
|
||||
const id =
|
||||
typeof args?.id === "string" && args.id.trim().length > 0
|
||||
? args.id.trim()
|
||||
: typeof args?.name === "string" && args.name.trim().length > 0
|
||||
? args.name.trim()
|
||||
: typeof args?.path === "string" && args.path.trim().length > 0
|
||||
? args.path.trim()
|
||||
: "";
|
||||
if (!id) {
|
||||
throw new Error("local uninstall id, name, or path is required");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: typeof args?.name === "string" ? args.name.trim() : undefined,
|
||||
path: typeof args?.path === "string" ? args.path.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallInputList(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput[] {
|
||||
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
|
||||
return rawEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
|
||||
const catalogEntries =
|
||||
catalog && typeof catalog === "object"
|
||||
? (catalog as Record<string, unknown>).entries
|
||||
: undefined;
|
||||
if (!Array.isArray(catalogEntries)) {
|
||||
throw new Error("marketplace catalog entries are required");
|
||||
}
|
||||
return catalogEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function marketplaceEntryKey(
|
||||
entry: Pick<MarketplaceInstallInput, "id" | "type">,
|
||||
) {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function redactOutput(value: string): string {
|
||||
const lines = value.split(/\r?\n/).map((line) => {
|
||||
if (!SECRET_PATTERN.test(line)) return line;
|
||||
return line
|
||||
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
|
||||
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
|
||||
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
|
||||
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
|
||||
.replace(
|
||||
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
});
|
||||
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
|
||||
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
|
||||
new Promise<SpawnResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
env: options.env ?? process.env,
|
||||
shell: options.shell ?? platform() === "win32",
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
|
||||
child.kill("SIGTERM");
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
forceKillTimeout.unref?.();
|
||||
timeout.unref?.();
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
const result = {
|
||||
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
function normalizeTransport(value: string | undefined): string {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertUrl(value: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
const [rawName, ...rest] = args;
|
||||
const name = rawName?.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP marketplace install requires a server name");
|
||||
}
|
||||
let transportType = "stdio";
|
||||
const headers: Record<string, string> = {};
|
||||
const targetArgs: string[] = [];
|
||||
let parsingMarketplaceOptions = true;
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
const arg = rest[index];
|
||||
if (parsingMarketplaceOptions && arg === "--") {
|
||||
targetArgs.push(...rest.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
|
||||
const next = rest[index + 1]?.trim();
|
||||
if (!next) throw new Error("--transport requires a value");
|
||||
transportType = normalizeTransport(next);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const shouldParseHeader =
|
||||
parsingMarketplaceOptions ||
|
||||
normalizeTransport(transportType) !== "stdio";
|
||||
if (
|
||||
shouldParseHeader &&
|
||||
(arg === "--header" || arg?.startsWith("--header="))
|
||||
) {
|
||||
const rawHeader =
|
||||
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
|
||||
if (!rawHeader) throw new Error("--header requires a value");
|
||||
const separatorIndex = rawHeader.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
const headerName = rawHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
|
||||
if (!headerName || !headerValue) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
headers[headerName] = headerValue;
|
||||
continue;
|
||||
}
|
||||
parsingMarketplaceOptions = false;
|
||||
targetArgs.push(arg);
|
||||
}
|
||||
transportType = normalizeTransport(transportType);
|
||||
if (transportType === "stdio") {
|
||||
if (Object.keys(headers).length > 0) {
|
||||
throw new Error("Stdio MCP installs do not support request headers.");
|
||||
}
|
||||
const [command, ...commandArgs] = targetArgs;
|
||||
if (!command?.trim()) {
|
||||
throw new Error("Stdio MCP install requires a command");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
command,
|
||||
args: commandArgs.length > 0 ? commandArgs : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error("Remote MCP install requires exactly one URL");
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertUrl(url);
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUserInstructionRemovalTarget(input: {
|
||||
type: "skill" | "workflow";
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): string {
|
||||
const filePath = resolve(input.path);
|
||||
const searchPaths =
|
||||
input.type === "skill"
|
||||
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
|
||||
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
|
||||
const containingRoot = searchPaths.find((root) =>
|
||||
isInsidePath(filePath, root),
|
||||
);
|
||||
if (!containingRoot) {
|
||||
throw new Error(
|
||||
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
|
||||
);
|
||||
}
|
||||
const stats = statSync(filePath, { throwIfNoEntry: false });
|
||||
if (!stats?.isFile()) {
|
||||
throw new Error(`${input.type} file does not exist: ${filePath}`);
|
||||
}
|
||||
if (input.type === "workflow") {
|
||||
return filePath;
|
||||
}
|
||||
const skillDir = dirname(filePath);
|
||||
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
|
||||
}
|
||||
|
||||
export async function uninstallLocalPrimitive(
|
||||
args?: Record<string, unknown>,
|
||||
options: { workspaceRoot?: string } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const input = readLocalUninstallInput(args);
|
||||
if (input.type === "mcp") {
|
||||
const name = input.name ?? input.id;
|
||||
const response = deleteMcpServer(name);
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${name}.`,
|
||||
details: { mcp: response },
|
||||
};
|
||||
}
|
||||
if (input.type === "plugin") {
|
||||
const result = await uninstallLocalPlugin({
|
||||
name: input.path ? undefined : (input.name ?? input.id),
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${result.name}.`,
|
||||
details: result as unknown as JsonRecord,
|
||||
};
|
||||
}
|
||||
if (input.type === "skill" || input.type === "workflow") {
|
||||
if (!input.path) {
|
||||
throw new Error(`${input.type} uninstall requires a path.`);
|
||||
}
|
||||
const target = resolveUserInstructionRemovalTarget({
|
||||
type: input.type,
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const stats = statSync(target, { throwIfNoEntry: false });
|
||||
if (!stats) {
|
||||
throw new Error(`${input.type} target does not exist: ${target}`);
|
||||
}
|
||||
rmSync(target, { recursive: stats.isDirectory(), force: true });
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${input.name ?? basename(target)}.`,
|
||||
details: { path: target },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported local uninstall type: ${input.type}`);
|
||||
}
|
||||
|
||||
function hashSource(source: string): string {
|
||||
return createHash("sha256").update(source).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replace(/^@/, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return sanitized || "plugin";
|
||||
}
|
||||
|
||||
function sanitizeSkillSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._]+/g, "-")
|
||||
.replace(/^[.-]+|[.-]+$/g, "")
|
||||
.slice(0, 255);
|
||||
return sanitized || "skill";
|
||||
}
|
||||
|
||||
function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
const slug = source.trim();
|
||||
if (!isOfficialPluginSlug(slug)) return undefined;
|
||||
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
|
||||
return join(
|
||||
resolveClineDir(),
|
||||
"plugins",
|
||||
"_installed",
|
||||
"official",
|
||||
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "plugin") return false;
|
||||
const [source] = entry.install.args ?? [];
|
||||
if (!source) return false;
|
||||
const installPath = getOfficialPluginInstallPath(source);
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return (
|
||||
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string | undefined): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const addCandidate = (value: string | undefined) => {
|
||||
const normalized = sanitizeSkillSegment(value ?? "");
|
||||
if (normalized && normalized !== "skill") {
|
||||
candidates.add(normalized);
|
||||
}
|
||||
};
|
||||
addCandidate(entry.id);
|
||||
addCandidate(entry.name);
|
||||
const installArgs = entry.install.args ?? [];
|
||||
for (let index = 0; index < installArgs.length; index++) {
|
||||
const arg = installArgs[index];
|
||||
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
|
||||
addCandidate(installArgs[index + 1]);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const skillFilter = arg.split("@").at(1);
|
||||
if (skillFilter) {
|
||||
addCandidate(skillFilter);
|
||||
}
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function getGlobalSkillPaths(skillName: string): string[] {
|
||||
return [
|
||||
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
|
||||
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
|
||||
].filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function ensureGlobalSkillsDirWritable(): void {
|
||||
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
|
||||
try {
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
const probePath = join(
|
||||
skillsDir,
|
||||
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
writeFileSync(probePath, "", { flag: "wx" });
|
||||
unlinkSync(probePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return findInstalledGlobalSkillName(entry) !== undefined;
|
||||
}
|
||||
|
||||
function findInstalledGlobalSkillName(
|
||||
entry: MarketplaceInstallInput,
|
||||
): string | undefined {
|
||||
if (entry.type !== "skill") return undefined;
|
||||
const candidates = getSkillInstallCandidates(entry);
|
||||
return candidates.find((candidate) =>
|
||||
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasMatchingInventoryItem(
|
||||
items: unknown,
|
||||
entry: MarketplaceInstallInput,
|
||||
): boolean {
|
||||
if (!Array.isArray(items)) return false;
|
||||
const candidates = new Set([
|
||||
normalizeMatchValue(entry.id),
|
||||
normalizeMatchValue(entry.name),
|
||||
...(entry.install.args ?? []).map(normalizeMatchValue),
|
||||
]);
|
||||
candidates.delete("");
|
||||
return items.some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as JsonRecord;
|
||||
const values = [
|
||||
typeof record.name === "string" ? record.name : undefined,
|
||||
typeof record.id === "string" ? record.id : undefined,
|
||||
typeof record.path === "string" ? record.path : undefined,
|
||||
]
|
||||
.map(normalizeMatchValue)
|
||||
.filter(Boolean);
|
||||
return values.some((value) => candidates.has(value));
|
||||
});
|
||||
}
|
||||
|
||||
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "mcp") return false;
|
||||
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const response = readMcpServersResponse();
|
||||
const servers = Array.isArray(response.servers) ? response.servers : [];
|
||||
return servers.some((server) => {
|
||||
if (!server || typeof server !== "object") return false;
|
||||
const record = server as JsonRecord;
|
||||
return record.name === input.name;
|
||||
});
|
||||
}
|
||||
|
||||
function isMarketplaceEntryInstalled(
|
||||
entry: MarketplaceInstallInput,
|
||||
inventory?: JsonRecord,
|
||||
): boolean {
|
||||
try {
|
||||
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
|
||||
if (entry.type === "plugin") {
|
||||
return (
|
||||
isOfficialPluginInstalled(entry) ||
|
||||
hasMatchingInventoryItem(inventory?.plugins, entry)
|
||||
);
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return isGlobalSkillInstalled(entry);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutput(result: SpawnResult): string | undefined {
|
||||
const output = redactOutput(
|
||||
[result.stdout, result.stderr].filter(Boolean).join("\n"),
|
||||
);
|
||||
return output.trim().length > 0 ? output.trim() : undefined;
|
||||
}
|
||||
|
||||
async function installSkill(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
if (isGlobalSkillInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
ensureGlobalSkillsDirWritable();
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
...(entry.install.args ?? []),
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
const output = commandOutput(result);
|
||||
if (/\bFailed to install\b/i.test(output ?? "")) {
|
||||
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
|
||||
}
|
||||
if (!isGlobalSkillInstalled(entry)) {
|
||||
throw new Error(
|
||||
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Plugin marketplace installs currently support exactly one source argument.",
|
||||
);
|
||||
}
|
||||
if (isOfficialPluginInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry, spawnCommand);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
let mcpDetails: JsonRecord | undefined;
|
||||
const result = await uninstallCoreMarketplaceEntry(
|
||||
entry satisfies MarketplaceEntryInput,
|
||||
{
|
||||
deleteMcpServer: (name) => {
|
||||
mcpDetails = deleteMcpServer(name);
|
||||
},
|
||||
spawnCommand: (command, commandArgs) =>
|
||||
spawnCommand(command, commandArgs),
|
||||
},
|
||||
);
|
||||
return {
|
||||
...(result satisfies MarketplaceActionResult),
|
||||
details: mcpDetails ? { mcp: mcpDetails } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return installMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return uninstallMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export function listMarketplaceInstalledEntries(
|
||||
args?: Record<string, unknown>,
|
||||
inventory?: JsonRecord,
|
||||
): MarketplaceInstallStatusResult {
|
||||
const entries = readInstallInputList(args);
|
||||
const installedKeys = entries
|
||||
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
|
||||
.map(marketplaceEntryKey);
|
||||
return { installedKeys };
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return installMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return uninstallMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
export function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function setMcpServerDisabled(
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
const name = String(input.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const previousName = String(
|
||||
input.previousName ?? input.previous_name ?? "",
|
||||
).trim();
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
}
|
||||
: {
|
||||
transport: {
|
||||
type: transportType === "sse" ? "sse" : "streamableHttp",
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
};
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function deleteMcpServer(name: string): JsonRecord {
|
||||
if (!name) throw new Error("server name is required");
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFetchHandler } from "./server";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
function createTestServer() {
|
||||
return {
|
||||
port: 3126,
|
||||
upgrade: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function createHandler(onShutdown = vi.fn()) {
|
||||
return createFetchHandler({} as SidecarContext, onShutdown);
|
||||
}
|
||||
|
||||
describe("sidecar HTTP origin checks", () => {
|
||||
it("rejects cross-origin shutdown preflight requests", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/shutdown", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
"access-control-request-method": "POST",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(response?.headers.get("access-control-allow-origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects cross-origin shutdown POST requests", async () => {
|
||||
const onShutdown = vi.fn();
|
||||
const server = createTestServer();
|
||||
const response = await createHandler(onShutdown)(
|
||||
new Request("http://127.0.0.1:3126/shutdown", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(onShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-origin websocket upgrades", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/transport", {
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
expect(server.upgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows desktop webview origins in preflight responses", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/api/marketplace/catalog", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(204);
|
||||
expect(response?.headers.get("access-control-allow-origin")).toBe(
|
||||
"tauri://localhost",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_MODE,
|
||||
@@ -15,49 +14,6 @@ type SidecarServer = {
|
||||
upgrade(req: Request): boolean;
|
||||
};
|
||||
|
||||
const TRUSTED_BROWSER_ORIGINS = new Set([
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://localhost:3125",
|
||||
"http://127.0.0.1:3125",
|
||||
]);
|
||||
|
||||
const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
function readOrigin(req: Request): string | undefined {
|
||||
const origin = req.headers.get("origin")?.trim();
|
||||
return origin ? origin : undefined;
|
||||
}
|
||||
|
||||
function isTrustedRequestOrigin(req: Request): boolean {
|
||||
const origin = readOrigin(req);
|
||||
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
|
||||
}
|
||||
|
||||
function corsHeaders(req: Request): Record<string, string> {
|
||||
const origin = readOrigin(req);
|
||||
return {
|
||||
"access-control-allow-headers": "accept, content-type",
|
||||
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
|
||||
? {
|
||||
"access-control-allow-origin": origin,
|
||||
vary: "Origin",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonHeaders(req: Request): Record<string, string> {
|
||||
return {
|
||||
...JSON_HEADERS,
|
||||
...corsHeaders(req),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -71,29 +27,6 @@ function jsonResponse(
|
||||
return JSON.stringify({ type: "response", id, ok, result, error });
|
||||
}
|
||||
|
||||
function createJsonResponse(
|
||||
req: Request,
|
||||
body: unknown,
|
||||
status = 200,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
|
||||
const EMPTY_MARKETPLACE_CATALOG = {
|
||||
version: 1,
|
||||
counts: {
|
||||
total: 0,
|
||||
plugins: 0,
|
||||
skills: 0,
|
||||
mcps: 0,
|
||||
},
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bun HTTP + WebSocket server
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -133,20 +66,13 @@ export function startServer(
|
||||
return { port: server.port };
|
||||
}
|
||||
|
||||
export function createFetchHandler(
|
||||
function createFetchHandler(
|
||||
_ctx: SidecarContext,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
) {
|
||||
return async (req: Request, server: SidecarServer) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return new Response(null, { status: 204, headers: corsHeaders(req) });
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -154,39 +80,15 @@ export function createFetchHandler(
|
||||
mode: SIDECAR_MODE,
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ headers: jsonHeaders(req) },
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
server.upgrade(req)
|
||||
) {
|
||||
if (url.pathname === "/transport" && server.upgrade(req)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(req, await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(req, {
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/shutdown" && req.method === "POST") {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 403,
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void onShutdown?.("code_sidecar_shutdown_endpoint")
|
||||
.catch((error) => {
|
||||
@@ -199,7 +101,7 @@ export function createFetchHandler(
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: jsonHeaders(req),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
AgentToolContext,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -104,7 +103,6 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?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,37 +179,30 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn desktop_backend_binary_names() -> Vec<String> {
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
let bundled_name = format!("code-sidecar{extension}");
|
||||
fn desktop_backend_binary_name() -> String {
|
||||
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
|
||||
if target_triple.is_empty() {
|
||||
return vec![bundled_name];
|
||||
return "code-sidecar".to_string();
|
||||
}
|
||||
|
||||
vec![
|
||||
bundled_name,
|
||||
format!("code-sidecar-{target_triple}{extension}"),
|
||||
]
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
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 mut candidates = Vec::new();
|
||||
if let Some(path) = explicit {
|
||||
candidates.push(path);
|
||||
}
|
||||
|
||||
for binary_name in desktop_backend_binary_names() {
|
||||
candidates.push(
|
||||
let candidates = [
|
||||
explicit,
|
||||
Some(
|
||||
PathBuf::from(&context.workspace_root)
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
@@ -217,23 +210,17 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
.join(&binary_name),
|
||||
);
|
||||
if let Some(path) = current_exe
|
||||
),
|
||||
current_exe
|
||||
.as_ref()
|
||||
.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| {
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name))),
|
||||
current_exe.as_ref().and_then(|path| {
|
||||
path.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(|parent| parent.join("Resources").join(&binary_name))
|
||||
}) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}),
|
||||
];
|
||||
candidates.into_iter().flatten().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.0.1",
|
||||
"version": "0.1.0",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -33,10 +33,6 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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,73 +9,39 @@
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
--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);
|
||||
--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);
|
||||
--radius: 0.5rem;
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -138,17 +104,8 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
.markdown * {
|
||||
@apply text-sm leading-relaxed;
|
||||
}
|
||||
.markdown + .markdown {
|
||||
@apply mt-2;
|
||||
}
|
||||
.markdown p {
|
||||
@apply my-2 first:mt-0 last:mb-0;
|
||||
@apply text-sm;
|
||||
}
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
|
||||
@@ -22,21 +22,17 @@ 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)}`;
|
||||
@@ -74,24 +70,17 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [view, setView] = useState<"chat" | "diff" | "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) => {
|
||||
@@ -109,7 +98,6 @@ export default function Home() {
|
||||
return [...prev, { id: threadId, historySession: session }];
|
||||
});
|
||||
setActiveThreadId(threadId);
|
||||
setView("chat");
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
@@ -188,12 +176,6 @@ 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 (
|
||||
<>
|
||||
@@ -206,18 +188,13 @@ export default function Home() {
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
onNewThread={handleNewThread}
|
||||
sessionHistory={sessionHistory}
|
||||
onOpenSession={handleOpenSession}
|
||||
setView={setView}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
{activeThread ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
@@ -264,7 +241,6 @@ function ChatThreadPane({
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState,
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
config,
|
||||
@@ -478,12 +454,7 @@ function ChatThreadPane({
|
||||
async (preferredWorkspace?: string) => {
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces((current) =>
|
||||
current.length === results.length &&
|
||||
current.every((workspace, index) => workspace === results[index])
|
||||
? current
|
||||
: results,
|
||||
);
|
||||
setWorkspaces(results);
|
||||
} finally {
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
@@ -624,26 +595,6 @@ 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);
|
||||
@@ -876,7 +827,9 @@ function ChatThreadPane({
|
||||
workspaceRoot: resolvedWorkspaceRoot,
|
||||
workspaces,
|
||||
listWorkspaces,
|
||||
refreshWorkspaces,
|
||||
refreshWorkspaces: async () => {
|
||||
await refreshWorkspaces();
|
||||
},
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
}),
|
||||
@@ -898,17 +851,8 @@ 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 === "unavailable"
|
||||
? "Desktop backend unavailable"
|
||||
: chatTransportState !== "connected"
|
||||
? "Connecting..."
|
||||
: "Loading..."}
|
||||
{chatTransportState !== "connected" ? "Connecting..." : "Loading..."}
|
||||
</p>
|
||||
{chatTransportError ? (
|
||||
<p className="max-w-xl px-6 text-center text-xs text-muted-foreground">
|
||||
{chatTransportError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1015,7 +959,6 @@ function ChatThreadPane({
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
@@ -1053,10 +996,8 @@ 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 px-4">
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-card 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-chart-2">+{additions}</span>
|
||||
<span className="text-primary">+{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/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",
|
||||
"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",
|
||||
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 { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
@@ -66,62 +66,19 @@ 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.5"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
gemini: ["gemini-2.5-pro"],
|
||||
};
|
||||
|
||||
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
gemini: ["gemini-2.5-pro"],
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -192,17 +149,12 @@ 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>;
|
||||
@@ -231,15 +183,12 @@ export function ChatInputBar({
|
||||
provider,
|
||||
model,
|
||||
mode,
|
||||
thinking,
|
||||
reasoningEffort,
|
||||
gitBranch,
|
||||
promptInput,
|
||||
onPromptInputChange,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModeToggle,
|
||||
onReasoningChange,
|
||||
onRefreshGitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
@@ -270,9 +219,10 @@ 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>(
|
||||
@@ -308,35 +258,13 @@ export function ChatInputBar({
|
||||
}
|
||||
return `${total.toLocaleString()} tokens`;
|
||||
}, [summary.tokensIn, summary.tokensOut]);
|
||||
const effortIndex = useMemo(
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
[reasoningEffort, thinking],
|
||||
);
|
||||
const effortLabel = modelSupportsReasoning
|
||||
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
|
||||
: "None";
|
||||
const effortLabel = effortLevels[effortIndex];
|
||||
const handleEffortCycle = useCallback(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
setEffortIndex((current) => (current + 1) % effortLevels.length);
|
||||
}, [effortLevels.length, modelSupportsReasoning]);
|
||||
|
||||
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
|
||||
setEditingQueuedPromptId(item.id);
|
||||
@@ -402,6 +330,20 @@ 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);
|
||||
@@ -806,7 +748,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 overflow-y-auto 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 bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onChange={(e) => {
|
||||
onPromptInputChange(e.target.value);
|
||||
setCursorIndex(
|
||||
@@ -818,8 +760,6 @@ 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) {
|
||||
@@ -895,14 +835,10 @@ export function ChatInputBar({
|
||||
placeholder={
|
||||
isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
: "Enter your question or type / for workflow or @ to attach files"
|
||||
}
|
||||
ref={promptInputRef}
|
||||
rows={
|
||||
promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
}
|
||||
rows={1}
|
||||
value={promptInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,22 +3,19 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
FileIcon,
|
||||
FileSearch,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
SplitIcon,
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
@@ -31,7 +28,6 @@ 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";
|
||||
@@ -40,11 +36,7 @@ import { WelcomeScreen } from "./welcome-chat";
|
||||
type ChatMessagesProps = {
|
||||
sessionId: string | null;
|
||||
status: ChatSessionStatus;
|
||||
chatTransportState?:
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
chatTransportState?: "connecting" | "reconnecting" | "connected";
|
||||
isSessionSwitching?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -373,10 +365,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-x-hidden overflow-y-auto"
|
||||
className="h-full min-h-0 min-w-0 overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
<div className="relative mx-auto w-full px-6 py-6">
|
||||
{showIdleDetails ? (
|
||||
<WelcomeScreen
|
||||
provider={provider}
|
||||
@@ -385,7 +377,7 @@ function ChatMessagesImpl({
|
||||
quickActions={[]}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
<div className="flex flex-col gap-2 w-full h-full">
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
@@ -482,9 +474,7 @@ function ChatMessagesImpl({
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{chatTransportState === "reconnecting"
|
||||
? "Reconnecting chat..."
|
||||
: chatTransportState === "unavailable"
|
||||
? "Chat backend unavailable"
|
||||
: "Connecting chat..."}
|
||||
: "Connecting chat..."}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
@@ -579,7 +569,7 @@ function ToolApprovalPanel({
|
||||
Request {item.requestId}
|
||||
{item.iteration != null ? ` · Iteration ${item.iteration}` : ""}
|
||||
</div>
|
||||
<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">
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
|
||||
{formatApprovalInput(item.input)}
|
||||
</pre>
|
||||
{error ? (
|
||||
@@ -732,17 +722,6 @@ 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} />;
|
||||
@@ -753,106 +732,71 @@ function MessageBubble({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
className={cn("flex", isUser ? "justify-end" : "justify-start w-full")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"space-y-2 pl-3 text-sm",
|
||||
isUser && "bg-card text-foreground/80 max-w-[50%]",
|
||||
!isUser && !isError && "text-foreground w-full",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
<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,
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
{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>
|
||||
<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" />
|
||||
)}
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
@@ -861,61 +805,30 @@ function MessageBubble({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
{!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"
|
||||
>
|
||||
{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>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<GitBranch className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -937,18 +850,17 @@ function ReasoningBlock({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
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"
|
||||
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
Thinking
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<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">
|
||||
<div className="mt-1 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{displayContent}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -966,10 +878,6 @@ type ToolPayload = {
|
||||
type ToolSummary = {
|
||||
label: string;
|
||||
details: string[];
|
||||
diff?: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
};
|
||||
|
||||
function pruneRequestMap<T extends string>(
|
||||
@@ -1058,12 +966,7 @@ function classifyTool(
|
||||
].includes(normalized)
|
||||
)
|
||||
return "exploration";
|
||||
if (
|
||||
["editor", "edit_file", "edit", "apply_patch", "apply-patch"].includes(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return "file-edit";
|
||||
if (["editor", "edit_file", "edit"].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";
|
||||
@@ -1082,62 +985,6 @@ 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;
|
||||
@@ -1178,10 +1025,10 @@ function buildToolSummary(
|
||||
const inputObject = asRecord(input);
|
||||
|
||||
if (["read_files", "file_read", "file-read"].includes(normalized)) {
|
||||
const files = extractReadFilePaths(input);
|
||||
const files = asStringArray(inputObject?.file_paths);
|
||||
if (files.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
|
||||
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(files.length, "file")}`,
|
||||
details: files.map(
|
||||
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
|
||||
),
|
||||
@@ -1200,8 +1047,14 @@ function buildToolSummary(
|
||||
}
|
||||
|
||||
if (["run_commands", "bash"].includes(normalized)) {
|
||||
const commands = extractCommands(input);
|
||||
if (commands.length > 0) {
|
||||
const commands = asStringArray(inputObject?.commands);
|
||||
if (commands.length === 1) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${commands[0]}`,
|
||||
details: [commands[0]],
|
||||
};
|
||||
}
|
||||
if (commands.length > 1) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
|
||||
details: commands.map((command) => command.trim()),
|
||||
@@ -1231,44 +1084,9 @@ 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
|
||||
: inputObject?.insert_line != null
|
||||
? "insert"
|
||||
: typeof inputObject?.old_text === "string"
|
||||
? "str_replace"
|
||||
: typeof inputObject?.new_text === "string"
|
||||
? "create"
|
||||
: "edit";
|
||||
typeof inputObject?.command === "string" ? inputObject.command : "edit";
|
||||
const path =
|
||||
typeof inputObject?.path === "string"
|
||||
? toDisplayPath(inputObject.path)
|
||||
@@ -1289,12 +1107,14 @@ 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, details: [] };
|
||||
return {
|
||||
label: `${detail} +${diff.additions} -${diff.deletions}`,
|
||||
details: [detail],
|
||||
};
|
||||
}
|
||||
return { label: detail, details: [] };
|
||||
return { label: detail, details: [detail] };
|
||||
}
|
||||
|
||||
const query =
|
||||
@@ -1342,17 +1162,13 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
hookEventName === "history_tool_use" ||
|
||||
(Boolean(payload) && payload?.result == null && !payload?.isError);
|
||||
const kind = classifyTool(toolName);
|
||||
const isFileRead = ["read_files", "file_read", "file-read"].includes(
|
||||
toolName.toLowerCase(),
|
||||
);
|
||||
const Icon = isFileRead
|
||||
? FileIcon
|
||||
: kind === "exploration"
|
||||
const Icon =
|
||||
kind === "exploration"
|
||||
? Search
|
||||
: kind === "file-edit"
|
||||
? FileEdit
|
||||
: kind === "bash"
|
||||
? SquareTerminalIcon
|
||||
? Terminal
|
||||
: kind === "spawn"
|
||||
? Bot
|
||||
: FileSearch;
|
||||
@@ -1364,52 +1180,39 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
details.length > 1 || Boolean(inputPreview || resultPreview);
|
||||
|
||||
return (
|
||||
<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")}
|
||||
>
|
||||
<div className="flex justify-start w-full">
|
||||
<div className={cn("w-full rounded-xl text-xs")}>
|
||||
<Button
|
||||
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"
|
||||
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
<AlertCircle className="size-3 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
<Icon className="size-3" />
|
||||
)}
|
||||
<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}
|
||||
<span>{summary.label}</span>
|
||||
{hasExpandedSections ? (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
|
||||
<div className="pl-8 text-muted-foreground">
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
<div className="text-xxs" key={`${message.id}_${detail}`}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
@@ -1420,7 +1223,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -1432,7 +1235,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
<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">
|
||||
{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 can I do for you?
|
||||
What would you like to build?
|
||||
</h1>
|
||||
<p className="mt-2 text-balance text-center text-muted-foreground">
|
||||
Let's explore, edit, and ship code together!
|
||||
Start a conversation to explore, edit, and ship code together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+624
-1095
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -34,7 +35,6 @@ 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,10 +203,7 @@ export function McpServersContent() {
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshServers();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
void refreshServers();
|
||||
}, [refreshServers]);
|
||||
|
||||
const toggleServer = async (server: McpServer, disabled: boolean) => {
|
||||
@@ -405,24 +402,18 @@ export function McpServersContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
From settings file
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -432,138 +423,151 @@ 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>
|
||||
|
||||
<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 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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{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",
|
||||
)}
|
||||
/>
|
||||
<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}`}
|
||||
{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",
|
||||
)}
|
||||
/>
|
||||
<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) => {
|
||||
@@ -848,6 +852,6 @@ export function McpServersContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageFrame>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
+116
-292
@@ -2,21 +2,18 @@
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -85,146 +82,94 @@ 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={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 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>
|
||||
|
||||
{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 flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
|
||||
{providers.map((prov) => (
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
|
||||
key={prov.id}
|
||||
>
|
||||
<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">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
<p className="text-xs 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>
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
@@ -242,7 +187,6 @@ export function ProviderDetailContent({
|
||||
modelsError,
|
||||
onOAuthLogin,
|
||||
oauthLoginPending = false,
|
||||
variant = "page",
|
||||
}: {
|
||||
provider: Provider;
|
||||
onBack: () => void;
|
||||
@@ -252,49 +196,18 @@ 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));
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfigValues(getInitialConfigValues(provider));
|
||||
}, [provider]);
|
||||
|
||||
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,
|
||||
@@ -319,83 +232,46 @@ 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={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Back + title */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<Button
|
||||
aria-label={
|
||||
isPanel ? "Close provider details" : "Back to providers"
|
||||
}
|
||||
aria-label="Back to providers"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
>
|
||||
{isPanel ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{provider.name}
|
||||
</h1>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{configFields.length > 0 ? (
|
||||
<section
|
||||
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<section className="mb-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
{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
|
||||
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">
|
||||
<div key={field.path}>
|
||||
<header className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{field.label}
|
||||
</h3>
|
||||
{field.description ? (
|
||||
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{field.type === "boolean" ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
@@ -408,7 +284,7 @@ export function ProviderDetailContent({
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
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"
|
||||
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"
|
||||
onChange={(event) =>
|
||||
commitField(field, event.target.value)
|
||||
}
|
||||
@@ -425,12 +301,12 @@ export function ProviderDetailContent({
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
|
||||
{field.type === "url" ? (
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Input
|
||||
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onBlur={() => commitField(field, valueText)}
|
||||
onChange={(event) =>
|
||||
setLocalConfigValues((current) => ({
|
||||
@@ -516,18 +392,10 @@ export function ProviderDetailContent({
|
||||
) : null}
|
||||
|
||||
{/* Models section */}
|
||||
<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>
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Models</h3>
|
||||
<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"
|
||||
@@ -546,83 +414,39 @@ 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>
|
||||
) : 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}
|
||||
>
|
||||
<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>
|
||||
) : 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"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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
@@ -1,32 +0,0 @@
|
||||
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,8 +1,9 @@
|
||||
import { X } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, 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,
|
||||
@@ -10,25 +11,20 @@ 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 { ChannelsContent } from "./channels-view";
|
||||
import { CustomizationSectionView, RulesView } from "./extensions-view";
|
||||
import { primeExtensionsListsCache, RulesView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
toSettingsPatch,
|
||||
} from "./provider-list-view";
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
import {
|
||||
primeRoutineOverviewCache,
|
||||
RoutineSchedulesContent,
|
||||
} from "./routine-view";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Settings nav categories
|
||||
@@ -37,15 +33,14 @@ import { toSettingsPatch } from "./settings-patch";
|
||||
const navCategories = [
|
||||
"General",
|
||||
"Providers",
|
||||
"MCP",
|
||||
"Marketplace",
|
||||
"Extensions",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"MCP",
|
||||
"Routine",
|
||||
"Features",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
type NavCategory = (typeof navCategories)[number];
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -58,18 +53,9 @@ let providerCatalogCache: {
|
||||
// Component
|
||||
// -----------------------------------------------------------
|
||||
|
||||
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);
|
||||
export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
const [activeNav, setActiveNav] = useState<NavCategory>("Providers");
|
||||
const [providersExpanded, setProvidersExpanded] = useState(true);
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
() => providerCatalogCache?.providers ?? [],
|
||||
);
|
||||
@@ -139,14 +125,14 @@ export function SettingsView({
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNav !== "Providers") {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderCatalog();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [activeNav, loadProviderCatalog]);
|
||||
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]);
|
||||
|
||||
const persistProviderSettings = useCallback(
|
||||
async (
|
||||
@@ -251,12 +237,13 @@ export function SettingsView({
|
||||
[setProvidersWithCache],
|
||||
);
|
||||
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
const selectedProvider = selectedProviderId
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -289,7 +276,6 @@ export function SettingsView({
|
||||
|
||||
const openProviderDetail = (id: string) => {
|
||||
setActiveNav("Providers");
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(id);
|
||||
};
|
||||
|
||||
@@ -303,14 +289,10 @@ export function SettingsView({
|
||||
if (!selected || (selected.modelList?.length ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderModels(selectedProviderId);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
void loadProviderModels(selectedProviderId);
|
||||
}, [loadProviderModels, providers, selectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
@@ -337,102 +319,10 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
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 */}
|
||||
@@ -454,68 +344,144 @@ export function SettingsView({
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">{content}</div>
|
||||
<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>
|
||||
</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,8 +28,6 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
mode: "act",
|
||||
systemPrompt: undefined,
|
||||
maxIterations: undefined,
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
enableTools: true,
|
||||
enableSpawn: undefined,
|
||||
enableTeams: undefined,
|
||||
|
||||
@@ -116,13 +116,10 @@ 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,11 +101,7 @@ export type ChatWsChunkEvent = {
|
||||
event: AgentChunkEvent;
|
||||
};
|
||||
|
||||
export type ChatTransportState =
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
export type ChatTransportState = "connecting" | "reconnecting" | "connected";
|
||||
|
||||
export type CoreLogChunk = {
|
||||
level?: string;
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildSessionDiffState,
|
||||
type SessionHookEvent,
|
||||
EMPTY_DIFF_SUMMARY,
|
||||
type SessionDiffSummary,
|
||||
type SessionFileDiff,
|
||||
@@ -242,9 +241,6 @@ 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(() => {
|
||||
@@ -497,50 +493,6 @@ 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) {
|
||||
@@ -838,10 +790,7 @@ export function useChatSession() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeTransport = desktopClient.subscribeTransportState(
|
||||
(state) => {
|
||||
setChatTransportState(state);
|
||||
setChatTransportError(desktopClient.getTransportError());
|
||||
},
|
||||
setChatTransportState,
|
||||
);
|
||||
const unsubscribeEvents = desktopClient.subscribe(
|
||||
"chat_event",
|
||||
@@ -1720,7 +1669,6 @@ export function useChatSession() {
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState,
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,6 @@ 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,9 +18,8 @@ async function tryTauriInvoke<T>(
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return await invoke<T>(command, args);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Tauri invoke failed for ${command}: ${message}`);
|
||||
} catch {
|
||||
throw new Error(`Tauri invoke unavailable for command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +37,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`).
|
||||
*/
|
||||
export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
async function resolveBackendEndpoint(): Promise<string> {
|
||||
if (resolvedEndpointCache) return resolvedEndpointCache;
|
||||
|
||||
// 1. Explicit injection from sidecar or test harness.
|
||||
@@ -52,7 +51,7 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
}
|
||||
|
||||
// 2. Tauri command (full desktop app).
|
||||
if (isTauriAvailable()) {
|
||||
try {
|
||||
const endpoint = await tryTauriInvoke<string>(
|
||||
"get_desktop_backend_endpoint",
|
||||
);
|
||||
@@ -61,26 +60,15 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
resolvedEndpointCache = trimmed;
|
||||
return resolvedEndpointCache;
|
||||
}
|
||||
throw new Error("Tauri returned an empty desktop backend endpoint");
|
||||
} catch {
|
||||
// Tauri not available — fall through to default.
|
||||
}
|
||||
|
||||
// 3. Default sidecar port for local dev mode without the Tauri bridge.
|
||||
// 3. Default sidecar port for local dev mode.
|
||||
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;
|
||||
@@ -114,7 +102,6 @@ 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;
|
||||
|
||||
@@ -129,7 +116,7 @@ class DesktopClient {
|
||||
if (this.endpoint?.trim()) {
|
||||
return this.endpoint;
|
||||
}
|
||||
const endpoint = await resolveDesktopBackendWsEndpoint();
|
||||
const endpoint = await resolveBackendEndpoint();
|
||||
this.endpoint = endpoint;
|
||||
return this.endpoint;
|
||||
}
|
||||
@@ -216,7 +203,6 @@ class DesktopClient {
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.hasConnectedOnce = true;
|
||||
this.transportError = null;
|
||||
this.setTransportState("connected");
|
||||
resolve();
|
||||
};
|
||||
@@ -231,9 +217,7 @@ class DesktopClient {
|
||||
this.socket = null;
|
||||
}
|
||||
if (this.transportState !== "connected") {
|
||||
reject(
|
||||
new Error(`Desktop backend transport unavailable at ${endpoint}`),
|
||||
);
|
||||
reject(new Error("Desktop backend transport unavailable"));
|
||||
return;
|
||||
}
|
||||
this.setTransportState("reconnecting");
|
||||
@@ -241,18 +225,9 @@ class DesktopClient {
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
});
|
||||
})()
|
||||
.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;
|
||||
});
|
||||
})().finally(() => {
|
||||
this.connectPromise = null;
|
||||
});
|
||||
|
||||
return this.connectPromise;
|
||||
}
|
||||
@@ -332,10 +307,6 @@ class DesktopClient {
|
||||
getTransportState(): DesktopTransportState {
|
||||
return this.transportState;
|
||||
}
|
||||
|
||||
getTransportError(): string | null {
|
||||
return this.transportError;
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopClient = new DesktopClient();
|
||||
|
||||
@@ -25,11 +25,7 @@ export type DesktopTransportMessage =
|
||||
| DesktopTransportResponse
|
||||
| DesktopTransportEvent;
|
||||
|
||||
export type DesktopTransportState =
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
export type DesktopTransportState = "connecting" | "reconnecting" | "connected";
|
||||
|
||||
export type DesktopBackendReadyPayload = {
|
||||
endpoint: string;
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"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,7 +46,6 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -59,39 +59,6 @@ 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 ?? "";
|
||||
}
|
||||
@@ -134,7 +101,7 @@ function stripApplyPatchWrapperLines(lines: string[]): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseApplyPatchInput(input: string): SessionFileDiff[] {
|
||||
function parseApplyPatchInput(input: string): SessionFileDiff[] {
|
||||
const lines = stripApplyPatchWrapperLines(
|
||||
input.split("\n").map((line) => line.replace(/\r$/, "")),
|
||||
);
|
||||
@@ -365,20 +332,12 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
}
|
||||
|
||||
const input = asRecord(event.toolInput);
|
||||
const output = normalizeToolOutput(event.toolOutput);
|
||||
const output = asRecord(event.toolOutput);
|
||||
if (!input || !output || output.success === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 command = toStringValue(input.command);
|
||||
const pathFromInput = toStringValue(input.path);
|
||||
const query = toStringValue(output.query);
|
||||
const pathFromQuery = query?.includes(":")
|
||||
@@ -389,10 +348,10 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultText = toStringValue(output.result) ?? "";
|
||||
|
||||
if (command === "str_replace" && !resultText.startsWith("File created")) {
|
||||
const parsed = parseDiffFromEditorResult(resultText);
|
||||
if (command === "str_replace") {
|
||||
const parsed = parseDiffFromEditorResult(
|
||||
toStringValue(output.result) ?? "",
|
||||
);
|
||||
return {
|
||||
path,
|
||||
additions: parsed.additions,
|
||||
@@ -401,12 +360,9 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "create" || command === "insert" || command === "str_replace") {
|
||||
if (command === "create" || command === "insert") {
|
||||
const newContent =
|
||||
toStringValue(input.new_text) ??
|
||||
toStringValue(input.file_text) ??
|
||||
toStringValue(input.new_str) ??
|
||||
"";
|
||||
toStringValue(input.file_text) ?? toStringValue(input.new_str) ?? "";
|
||||
return {
|
||||
path,
|
||||
additions: countAddedLines(newContent),
|
||||
@@ -436,15 +392,13 @@ function parseApplyPatchFileDiffs(event: SessionHookEvent): SessionFileDiff[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const output = normalizeToolOutput(event.toolOutput);
|
||||
if (!output || output.success === false) {
|
||||
const input = asRecord(event.toolInput);
|
||||
const output = asRecord(event.toolOutput);
|
||||
if (!input || !output || output.success === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// apply_patch accepts either { input: string } or a raw patch string.
|
||||
const patchInput =
|
||||
toStringValue(event.toolInput) ??
|
||||
toStringValue(asRecord(event.toolInput)?.input);
|
||||
const patchInput = toStringValue(input.input);
|
||||
if (!patchInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -39,18 +39,17 @@ 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";
|
||||
|
||||
@@ -70,10 +69,10 @@ export type {
|
||||
ClineCoreOptions,
|
||||
ClineCoreSettingsApi,
|
||||
ClineCoreStartInput,
|
||||
CompareCheckpointInput,
|
||||
CompareCheckpointResult,
|
||||
HubOptions,
|
||||
RemoteOptions,
|
||||
CompareCheckpointInput,
|
||||
CompareCheckpointResult,
|
||||
RestoreInput,
|
||||
RestoreOptions,
|
||||
RestoreResult,
|
||||
@@ -621,13 +620,4 @@ 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,7 +2,6 @@ import type {
|
||||
PendingPromptsRuntimeService,
|
||||
PendingPromptsServiceApi,
|
||||
RuntimeHost,
|
||||
SessionConnectionRuntimeService,
|
||||
SessionModelRuntimeService,
|
||||
SessionUsageRuntimeService,
|
||||
} from "../runtime/host/runtime-host";
|
||||
@@ -28,7 +27,6 @@ export type RuntimeHostServiceExtensions = RuntimeHost &
|
||||
Partial<
|
||||
PendingPromptsRuntimeService &
|
||||
SessionUsageRuntimeService &
|
||||
SessionConnectionRuntimeService &
|
||||
SessionModelRuntimeService
|
||||
>;
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
CoreCompactionSummarizerConfig,
|
||||
} from "../../types/config";
|
||||
import type { ProviderConfig } from "../../types/provider-settings";
|
||||
import {
|
||||
buildBudgetProjection,
|
||||
type BudgetProjectionResult,
|
||||
} from "./budget-projection";
|
||||
import {
|
||||
buildSummaryMessage,
|
||||
buildSummaryRequest,
|
||||
@@ -20,6 +24,43 @@ import {
|
||||
serializeConversation,
|
||||
} from "./compaction-shared";
|
||||
|
||||
const MIN_AGENTIC_SUMMARY_INPUT_TOKENS = 1_024;
|
||||
|
||||
function resolveProviderMaxInputTokens(
|
||||
providerConfig: ProviderConfig,
|
||||
): number | undefined {
|
||||
const explicit = providerConfig.maxInputTokens;
|
||||
if (typeof explicit === "number" && Number.isFinite(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
const modelInfoLimit =
|
||||
providerConfig.modelInfo?.maxInputTokens ??
|
||||
providerConfig.modelInfo?.contextWindow;
|
||||
if (typeof modelInfoLimit === "number" && Number.isFinite(modelInfoLimit)) {
|
||||
return modelInfoLimit;
|
||||
}
|
||||
const knownModelInfo = providerConfig.knownModels?.[providerConfig.modelId];
|
||||
const knownModelLimit =
|
||||
knownModelInfo?.maxInputTokens ?? knownModelInfo?.contextWindow;
|
||||
if (typeof knownModelLimit === "number" && Number.isFinite(knownModelLimit)) {
|
||||
return knownModelLimit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAgenticSummaryInputBudget(options: {
|
||||
messages: CoreCompactionContext["messages"];
|
||||
targetTokens: number;
|
||||
estimateMessageTokens: EstimateMessageTokens;
|
||||
}): BudgetProjectionResult {
|
||||
return buildBudgetProjection({
|
||||
messages: options.messages,
|
||||
targetTokens: Math.max(1, options.targetTokens),
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
}
|
||||
|
||||
async function generateSummary(options: {
|
||||
providerConfig: ProviderConfig;
|
||||
request: string;
|
||||
@@ -92,8 +133,80 @@ export async function runAgenticCompaction(options: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileOps = extractFileOps(messagesToSummarize);
|
||||
const conversationText = serializeConversation(newMessagesToFold);
|
||||
const preProjectionFileOps = extractFileOps(messagesToSummarize);
|
||||
const summarizerProviderConfig = resolveSummarizerConfig({
|
||||
activeProviderConfig: options.providerConfig,
|
||||
summarizer: options.summarizer,
|
||||
});
|
||||
const resolvedSummarizerInputLimit = resolveProviderMaxInputTokens(
|
||||
summarizerProviderConfig,
|
||||
);
|
||||
const canUseActiveContextLimit = options.summarizer === undefined;
|
||||
const activeCompactionInputLimit = Math.max(
|
||||
options.context.maxInputTokens,
|
||||
options.context.triggerTokens,
|
||||
MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
|
||||
);
|
||||
if (
|
||||
resolvedSummarizerInputLimit === undefined &&
|
||||
!canUseActiveContextLimit
|
||||
) {
|
||||
options.logger?.log(
|
||||
"Agentic compaction summarizer has no known input limit; using conservative summary budget",
|
||||
{
|
||||
severity: "warn",
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
fallbackInputLimit: MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
|
||||
},
|
||||
);
|
||||
}
|
||||
const summarizerInputLimit =
|
||||
resolvedSummarizerInputLimit ??
|
||||
(canUseActiveContextLimit
|
||||
? activeCompactionInputLimit
|
||||
: MIN_AGENTIC_SUMMARY_INPUT_TOKENS);
|
||||
const summaryRequestOverheadTokens = estimateTokens(
|
||||
buildSummaryRequest({
|
||||
previousSummary,
|
||||
conversationText: "",
|
||||
fileOps: preProjectionFileOps,
|
||||
}).length,
|
||||
);
|
||||
const availableSummaryInputTokens =
|
||||
summarizerInputLimit - summaryRequestOverheadTokens;
|
||||
if (availableSummaryInputTokens <= 0) {
|
||||
options.logger?.debug("Skipped agentic compaction: summarizer budget exhausted", {
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
summarizerInputLimit,
|
||||
summaryRequestOverheadTokens,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const summaryInputBudget = buildAgenticSummaryInputBudget({
|
||||
messages: newMessagesToFold,
|
||||
targetTokens: availableSummaryInputTokens,
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
if (summaryInputBudget.status === "failed") {
|
||||
options.logger?.log(
|
||||
"Skipped agentic compaction: summary input budget failed",
|
||||
{
|
||||
severity: "warn",
|
||||
budgetWarnings: summaryInputBudget.warnings.map(
|
||||
(warning) => warning.code,
|
||||
),
|
||||
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
|
||||
targetTokens: availableSummaryInputTokens,
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const fileOps = extractFileOps(summaryInputBudget.messages);
|
||||
const conversationText = serializeConversation(summaryInputBudget.messages);
|
||||
const summaryRequest = buildSummaryRequest({
|
||||
previousSummary,
|
||||
conversationText,
|
||||
@@ -108,14 +221,20 @@ export async function runAgenticCompaction(options: {
|
||||
summaryRequestChars: summaryRequest.length,
|
||||
summaryRequestEstimatedTokens: estimateTokens(summaryRequest.length),
|
||||
newMessagesJsonChars: safeJsonSize(newMessagesToFold),
|
||||
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
|
||||
summaryInputActions: summaryInputBudget.actions.length,
|
||||
summaryInputWarnings: summaryInputBudget.warnings.map(
|
||||
(warning) => warning.code,
|
||||
),
|
||||
summaryRequestOverheadTokens,
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
summarizerInputLimit,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
triggerTokens: options.context.triggerTokens,
|
||||
});
|
||||
const rawSummary = await generateSummary({
|
||||
providerConfig: resolveSummarizerConfig({
|
||||
activeProviderConfig: options.providerConfig,
|
||||
summarizer: options.summarizer,
|
||||
}),
|
||||
providerConfig: summarizerProviderConfig,
|
||||
request: summaryRequest,
|
||||
logger: options.logger,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CoreCompactionContext,
|
||||
CoreCompactionResult,
|
||||
} from "../../types/config";
|
||||
import { buildBudgetProjection } from "./budget-projection";
|
||||
import {
|
||||
DEFAULT_TARGET_RATIO,
|
||||
type EstimateMessageTokens,
|
||||
@@ -444,21 +445,46 @@ export function runBasicCompaction(options: {
|
||||
...candidates.map((candidate) => candidate.message),
|
||||
...protectedTail,
|
||||
];
|
||||
if (!haveMessagesChanged(options.context.messages, nextMessages)) {
|
||||
const budgeted = buildBudgetProjection({
|
||||
messages: nextMessages,
|
||||
targetTokens: totalTargetTokens,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
// This final projection owns the hard output budget. Unlike the earlier
|
||||
// basic candidate passes, it may drop the original first-user message when
|
||||
// preserving the latest typed prompt and coherent tool closures requires it.
|
||||
if (budgeted.status === "failed") {
|
||||
options.logger?.debug("Basic compaction returned best-effort projection", {
|
||||
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
|
||||
projectedTokens: budgeted.estimatedTokens,
|
||||
targetTokens: totalTargetTokens,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
}
|
||||
const resultMessages = budgeted.messages;
|
||||
|
||||
if (!haveMessagesChanged(options.context.messages, resultMessages)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const beforeTokens = beforeCompactableTokens + protectedTailTokens;
|
||||
const afterTokens = totalTokens + protectedTailTokens;
|
||||
const afterTokens = getTotalTokens(
|
||||
resultMessages,
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
options.logger?.debug("Performed basic compaction", {
|
||||
messagesBefore: options.context.messages.length,
|
||||
messagesAfter: nextMessages.length,
|
||||
messagesRemoved: options.context.messages.length - nextMessages.length,
|
||||
messagesAfter: resultMessages.length,
|
||||
messagesRemoved: options.context.messages.length - resultMessages.length,
|
||||
tokensBefore: beforeTokens,
|
||||
tokensAfter: afterTokens,
|
||||
budgetStatus: budgeted.status,
|
||||
budgetActions: budgeted.actions.length,
|
||||
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
|
||||
targetTokens: totalTargetTokens,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
|
||||
return { messages: nextMessages };
|
||||
return { messages: resultMessages };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
export type {
|
||||
BlockBudgetClass,
|
||||
BudgetAction,
|
||||
BudgetActionKind,
|
||||
BudgetActionReason,
|
||||
BudgetPath,
|
||||
BudgetPolicyIntent,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
ContentBlockBudgetClassification,
|
||||
LiveTailHandling,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,476 @@
|
||||
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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,670 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
@@ -16,12 +16,7 @@ import type { ProviderConfig } from "../../types/provider-settings";
|
||||
export const DEFAULT_MAX_INPUT_TOKENS = 128_000;
|
||||
export const DEFAULT_THRESHOLD_RATIO = 0.9;
|
||||
export const DEFAULT_TARGET_RATIO = 0.7;
|
||||
/**
|
||||
* Estimated output reserve for shared-context models that do not declare
|
||||
* `maxTokens`. Only consulted in that fallback; explicit input ceilings
|
||||
* (config or true input limits) never reserve output.
|
||||
*/
|
||||
export const FALLBACK_OUTPUT_RESERVE_TOKENS = 16_384;
|
||||
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
||||
export const DEFAULT_PRESERVE_RECENT_TOKENS = 20_000;
|
||||
export const DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS = 1_024;
|
||||
export const TOOL_RESULT_CHAR_LIMIT = 2_000;
|
||||
@@ -490,6 +485,7 @@ export function resolveSummarizerConfig(options: {
|
||||
apiKey: summarizer.apiKey ?? baseProviderConfig?.apiKey,
|
||||
baseUrl: summarizer.baseUrl ?? baseProviderConfig?.baseUrl,
|
||||
headers: summarizer.headers ?? baseProviderConfig?.headers,
|
||||
modelInfo: summarizer.modelInfo ?? baseProviderConfig?.modelInfo,
|
||||
knownModels: summarizer.knownModels ?? baseProviderConfig?.knownModels,
|
||||
maxOutputTokens:
|
||||
summarizer.maxOutputTokens ?? DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import type { CoreCompactionContext } from "../../types/config";
|
||||
import { buildAgenticSummaryInputBudget } from "./agentic-compaction";
|
||||
import { runBasicCompaction } from "./basic-compaction";
|
||||
import {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "./compaction";
|
||||
import {
|
||||
createTokenEstimator,
|
||||
estimateTokens,
|
||||
resolveSummarizerConfig,
|
||||
serializeMessage,
|
||||
TOOL_RESULT_CHAR_LIMIT,
|
||||
@@ -400,11 +402,25 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(compacted).toEqual([
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "user", content: "Read the latest file" },
|
||||
assistantToolUseMessage("tool-a"),
|
||||
toolResultMessage("tool-a", "latest result"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("budgets the complete basic compaction output including the latest turn", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "original task" },
|
||||
{ role: "assistant", content: "old assistant " + "x".repeat(10_000) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
assistantToolUseMessage("tool-live"),
|
||||
toolResultMessage("tool-live", "live result " + "y".repeat(10_000)),
|
||||
];
|
||||
|
||||
const compacted = runForcedBasicCompaction(messages, 700);
|
||||
|
||||
expect(totalJsonTokens(compacted)).toBeLessThanOrEqual(700);
|
||||
expect(JSON.stringify(compacted)).toContain("latest typed prompt");
|
||||
expectNoOrphanedToolPairs(compacted);
|
||||
});
|
||||
|
||||
it("does not compact a single typed user message", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "Only current request" },
|
||||
@@ -520,6 +536,23 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(anthropicConfig.maxOutputTokens).toBe(1_024);
|
||||
});
|
||||
|
||||
it("preserves summarizer modelInfo without a nested providerConfig", () => {
|
||||
const resolved = resolveSummarizerConfig({
|
||||
activeProviderConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
modelInfo: { id: "primary-model", maxInputTokens: 100_000 },
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
summarizer: {
|
||||
providerId: "openai",
|
||||
modelId: "small-summary",
|
||||
modelInfo: { id: "small-summary", maxInputTokens: 600 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.modelInfo?.maxInputTokens).toBe(600);
|
||||
});
|
||||
|
||||
it("summarizes older messages and keeps recent messages", async () => {
|
||||
const emitStatusNotice = vi.fn();
|
||||
createHandlerMock.mockReturnValue({
|
||||
@@ -772,6 +805,43 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(summarizerPrompt.length).toBeLessThan(longToolOutput.length);
|
||||
});
|
||||
|
||||
it("budgets agentic summary input before serialization", () => {
|
||||
const result = buildAgenticSummaryInputBudget({
|
||||
messages: [
|
||||
{ role: "user", content: "Run a large command" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-large",
|
||||
name: "execute_command",
|
||||
input: { command: "print-large-output" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-large",
|
||||
name: "execute_command",
|
||||
content: "x".repeat(50_000),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Latest typed prompt" },
|
||||
],
|
||||
targetTokens: 400,
|
||||
estimateMessageTokens: estimateJsonTokens,
|
||||
});
|
||||
|
||||
expect(result.estimatedTokens).toBeLessThanOrEqual(400);
|
||||
expect(JSON.stringify(result.messages)).toContain("Latest typed prompt");
|
||||
expect(result.actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never lands the agentic cut in the middle of a tool pair", async () => {
|
||||
// Repro for the "No tool call found for function call output" provider
|
||||
// error: findCutIndex used to walk back by token budget and could land
|
||||
@@ -946,6 +1016,79 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("budgets agentic summary input against the configured summarizer context window", async () => {
|
||||
let summaryRequest = "";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn((_system: string, messages: LlmsProviders.Message[]) => {
|
||||
summaryRequest = String(messages[0]?.content ?? "");
|
||||
return streamChunks([
|
||||
{ type: "text", id: "summary-small", text: "## Goal\nSummarized" },
|
||||
{ type: "done", id: "summary-small", success: true },
|
||||
]);
|
||||
}),
|
||||
});
|
||||
|
||||
const summarizerLimit = 600;
|
||||
const oversizedAssistant = "assistant details ".repeat(5_000);
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
providerConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
modelInfo: { id: "primary-model", maxInputTokens: 10_000 },
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
preserveRecentTokens: 1,
|
||||
reserveTokens: 5,
|
||||
summarizer: {
|
||||
providerId: "openai",
|
||||
modelId: "small-summary",
|
||||
modelInfo: {
|
||||
id: "small-summary",
|
||||
maxInputTokens: summarizerLimit,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages: [
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "assistant", content: oversizedAssistant },
|
||||
{ role: "user", content: "Latest turn" },
|
||||
{ role: "assistant", content: "Latest answer" },
|
||||
],
|
||||
apiMessages: [
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "assistant", content: oversizedAssistant },
|
||||
{ role: "user", content: "Latest turn" },
|
||||
{ role: "assistant", content: "Latest answer" },
|
||||
],
|
||||
model: {
|
||||
id: "primary-model",
|
||||
provider: "anthropic",
|
||||
info: { id: "primary-model", maxInputTokens: 10_000 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(estimateTokens(summaryRequest.length)).toBeLessThanOrEqual(
|
||||
summarizerLimit,
|
||||
);
|
||||
expect(summaryRequest).not.toContain(oversizedAssistant);
|
||||
});
|
||||
|
||||
it("uses basic compaction without calling the summarizer", async () => {
|
||||
const emitStatusNotice = vi.fn();
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
@@ -1105,7 +1248,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the true input budget when no context window is available", async () => {
|
||||
it("uses the default reserve when no trigger is configured", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted by reserve" }],
|
||||
}));
|
||||
@@ -1146,7 +1289,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(createHandlerMock).not.toHaveBeenCalled();
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(180);
|
||||
expect(context?.triggerTokens).toBe(0);
|
||||
expect(result?.messages).toEqual([
|
||||
{ role: "user", content: "Compacted by reserve" },
|
||||
]);
|
||||
@@ -1367,7 +1510,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(context?.targetTokens).toBe(39);
|
||||
});
|
||||
|
||||
it("reserves output once for a shared context window", async () => {
|
||||
it("derives input budget by reserving model max output tokens from context window", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
{ role: "user" as const, content: "Compacted by derived input budget" },
|
||||
@@ -1421,191 +1564,6 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not double-reserve output for mirrored Qwen context metadata", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted Qwen" }],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: { enabled: true, compact },
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "large prompt ".repeat(6_000) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "qwen/qwen3-32b",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "qwen/qwen3-32b",
|
||||
contextWindow: 40_960,
|
||||
maxInputTokens: 40_960,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.maxInputTokens).toBe(24_576);
|
||||
expect(context?.triggerTokens).toBe(22_118);
|
||||
expect(context?.thresholdRatio).toBe(22_118 / 24_576);
|
||||
});
|
||||
|
||||
it("caps shared-context output reserve at half the context window", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted capped output" }],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "kilo",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
providerConfig: {
|
||||
providerId: "kilo",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: { enabled: true, compact },
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "large prompt ".repeat(5_000) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "qwen/qwen3-32b",
|
||||
provider: "kilo",
|
||||
info: {
|
||||
id: "qwen/qwen3-32b",
|
||||
contextWindow: 40_960,
|
||||
maxInputTokens: 40_960,
|
||||
maxTokens: 40_960,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.maxInputTokens).toBe(20_480);
|
||||
expect(context?.triggerTokens).toBe(18_432);
|
||||
});
|
||||
|
||||
it("applies explicit threshold ratio to the usable budget", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted threshold" }],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: { enabled: true, thresholdRatio: 0.8, compact },
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "large prompt ".repeat(6_000) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "qwen/qwen3-32b",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "qwen/qwen3-32b",
|
||||
contextWindow: 40_960,
|
||||
maxInputTokens: 40_960,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(19_660);
|
||||
expect(context?.thresholdRatio).toBe(19_660 / 24_576);
|
||||
});
|
||||
|
||||
it("applies explicit reserve tokens to the usable budget", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted reserve" }],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "qwen/qwen3-32b",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: { enabled: true, reserveTokens: 4_096, compact },
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "large prompt ".repeat(6_000) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "qwen/qwen3-32b",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "qwen/qwen3-32b",
|
||||
contextWindow: 40_960,
|
||||
maxInputTokens: 40_960,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(20_480);
|
||||
expect(context?.thresholdRatio).toBe(20_480 / 24_576);
|
||||
});
|
||||
|
||||
it("uses the lower split input budget when it is below context-derived input budget", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
@@ -1983,7 +1941,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result?.messages.length).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it("preserves user image blocks during basic compaction sanitization", () => {
|
||||
it("drops old user image blocks during basic compaction sanitization", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
@@ -2019,7 +1977,6 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result?.messages).toBeDefined();
|
||||
expect(result?.messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Older user turn" },
|
||||
{ type: "image", data: "abc", mediaType: "image/png" },
|
||||
]);
|
||||
expect(result?.messages.at(-1)).toEqual({
|
||||
role: "user",
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
createTokenEstimator,
|
||||
DEFAULT_MAX_INPUT_TOKENS,
|
||||
DEFAULT_PRESERVE_RECENT_TOKENS,
|
||||
DEFAULT_RESERVE_TOKENS,
|
||||
DEFAULT_TARGET_RATIO,
|
||||
DEFAULT_THRESHOLD_RATIO,
|
||||
FALLBACK_OUTPUT_RESERVE_TOKENS,
|
||||
} from "./compaction-shared";
|
||||
|
||||
export interface ContextPipelinePrepareTurnInput {
|
||||
@@ -78,6 +78,7 @@ export interface ContextCompactionPrepareTurnOptions {
|
||||
manualTargetRatio?: number;
|
||||
}
|
||||
|
||||
const MIN_CONTEXT_DERIVED_INPUT_RATIO = 0.5;
|
||||
const LONG_CONVERSATION_TARGET_RATIO = 0.5;
|
||||
|
||||
function safeJsonSize(value: unknown): number {
|
||||
@@ -92,86 +93,35 @@ function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
type CompactionBudgetSource = "config" | "true_input" | "context" | "default";
|
||||
|
||||
interface CompactionBudget {
|
||||
ceilingTokens: number;
|
||||
usableBudgetTokens: number;
|
||||
outputReserveTokens: number;
|
||||
triggerTokens: number;
|
||||
thresholdRatio: number;
|
||||
ceilingSource: CompactionBudgetSource;
|
||||
reserveCapped: boolean;
|
||||
}
|
||||
|
||||
function resolveCompactionBudget(input: {
|
||||
config: CoreCompactionConfig;
|
||||
function resolveMaxInputTokens(input: {
|
||||
configMaxInputTokens?: number;
|
||||
modelMaxInputTokens?: number;
|
||||
contextWindow?: number;
|
||||
modelMaxTokens?: number;
|
||||
}): CompactionBudget {
|
||||
let ceilingTokens = DEFAULT_MAX_INPUT_TOKENS;
|
||||
let ceilingSource: CompactionBudgetSource = "default";
|
||||
|
||||
if (isPositiveFiniteNumber(input.config.maxInputTokens)) {
|
||||
ceilingTokens = input.config.maxInputTokens;
|
||||
ceilingSource = "config";
|
||||
} else if (
|
||||
isPositiveFiniteNumber(input.modelMaxInputTokens) &&
|
||||
(!isPositiveFiniteNumber(input.contextWindow) ||
|
||||
input.modelMaxInputTokens < input.contextWindow)
|
||||
) {
|
||||
ceilingTokens = input.modelMaxInputTokens;
|
||||
ceilingSource = "true_input";
|
||||
} else if (isPositiveFiniteNumber(input.contextWindow)) {
|
||||
ceilingTokens = input.contextWindow;
|
||||
ceilingSource = "context";
|
||||
}): number {
|
||||
const candidates: number[] = [];
|
||||
if (isPositiveFiniteNumber(input.configMaxInputTokens)) {
|
||||
candidates.push(input.configMaxInputTokens);
|
||||
}
|
||||
|
||||
// Output space is reserved once, and only when the ceiling is a shared
|
||||
// context window. Explicit config and true input limits already describe
|
||||
// input-only budgets, so reserving there would double-count output.
|
||||
const maxReserveTokens = Math.floor(ceilingTokens / 2);
|
||||
const outputReserveTokens =
|
||||
ceilingSource === "config" ||
|
||||
ceilingSource === "true_input" ||
|
||||
ceilingSource === "default"
|
||||
? 0
|
||||
: isPositiveFiniteNumber(input.modelMaxTokens)
|
||||
? Math.min(input.modelMaxTokens, maxReserveTokens)
|
||||
: Math.min(
|
||||
FALLBACK_OUTPUT_RESERVE_TOKENS,
|
||||
Math.floor(ceilingTokens * 0.25),
|
||||
);
|
||||
const usableBudgetTokens = Math.max(1, ceilingTokens - outputReserveTokens);
|
||||
|
||||
let triggerTokens: number;
|
||||
if (typeof input.config.reserveTokens === "number") {
|
||||
triggerTokens = Math.max(
|
||||
0,
|
||||
usableBudgetTokens - Math.max(0, input.config.reserveTokens),
|
||||
);
|
||||
} else if (typeof input.config.thresholdRatio === "number") {
|
||||
triggerTokens = Math.floor(
|
||||
usableBudgetTokens * input.config.thresholdRatio,
|
||||
);
|
||||
} else {
|
||||
triggerTokens = Math.floor(usableBudgetTokens * DEFAULT_THRESHOLD_RATIO);
|
||||
if (isPositiveFiniteNumber(input.modelMaxInputTokens)) {
|
||||
candidates.push(input.modelMaxInputTokens);
|
||||
}
|
||||
|
||||
return {
|
||||
ceilingTokens,
|
||||
usableBudgetTokens,
|
||||
outputReserveTokens,
|
||||
triggerTokens,
|
||||
thresholdRatio:
|
||||
usableBudgetTokens > 0 ? triggerTokens / usableBudgetTokens : 0,
|
||||
ceilingSource,
|
||||
reserveCapped:
|
||||
ceilingSource === "context" &&
|
||||
isPositiveFiniteNumber(input.modelMaxTokens) &&
|
||||
input.modelMaxTokens > maxReserveTokens,
|
||||
};
|
||||
if (isPositiveFiniteNumber(input.contextWindow)) {
|
||||
candidates.push(input.contextWindow);
|
||||
const derivedInputTokens = isPositiveFiniteNumber(input.modelMaxTokens)
|
||||
? input.contextWindow - input.modelMaxTokens
|
||||
: undefined;
|
||||
if (
|
||||
isPositiveFiniteNumber(derivedInputTokens) &&
|
||||
derivedInputTokens >=
|
||||
input.contextWindow * MIN_CONTEXT_DERIVED_INPUT_RATIO
|
||||
) {
|
||||
candidates.push(derivedInputTokens);
|
||||
}
|
||||
}
|
||||
return candidates.length > 0
|
||||
? Math.min(...candidates)
|
||||
: DEFAULT_MAX_INPUT_TOKENS;
|
||||
}
|
||||
|
||||
function summarizeToolResults(messages: CoreCompactionContext["messages"]): {
|
||||
@@ -239,6 +189,47 @@ const BUILTIN_COMPACTION_STRATEGIES = {
|
||||
}),
|
||||
} satisfies Record<CoreCompactionStrategy, BuiltinCompactionStrategyRunner>;
|
||||
|
||||
function resolveTriggerState(input: {
|
||||
inputTokens: number;
|
||||
maxInputTokens: number;
|
||||
config: CoreCompactionConfig;
|
||||
}): { shouldCompact: boolean; triggerTokens: number; thresholdRatio: number } {
|
||||
if (typeof input.config.reserveTokens === "number") {
|
||||
const reserveTokens = Math.max(0, input.config.reserveTokens);
|
||||
const triggerTokens = Math.max(0, input.maxInputTokens - reserveTokens);
|
||||
return {
|
||||
shouldCompact: input.inputTokens > triggerTokens,
|
||||
triggerTokens,
|
||||
thresholdRatio:
|
||||
input.maxInputTokens > 0 ? triggerTokens / input.maxInputTokens : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input.config.thresholdRatio === "number") {
|
||||
const thresholdRatio = input.config.thresholdRatio;
|
||||
const triggerTokens = input.maxInputTokens * thresholdRatio;
|
||||
return {
|
||||
shouldCompact: input.inputTokens > triggerTokens,
|
||||
triggerTokens,
|
||||
thresholdRatio,
|
||||
};
|
||||
}
|
||||
|
||||
const triggerTokens = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
input.maxInputTokens - DEFAULT_RESERVE_TOKENS,
|
||||
input.maxInputTokens * DEFAULT_THRESHOLD_RATIO,
|
||||
),
|
||||
);
|
||||
return {
|
||||
shouldCompact: input.inputTokens > triggerTokens,
|
||||
triggerTokens,
|
||||
thresholdRatio:
|
||||
input.maxInputTokens > 0 ? triggerTokens / input.maxInputTokens : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManualTargetState(input: {
|
||||
inputTokens: number;
|
||||
maxInputTokens: number;
|
||||
@@ -267,8 +258,7 @@ function resolveManualTargetState(input: {
|
||||
}
|
||||
|
||||
function resolveBasicTargetTokens(input: {
|
||||
ceilingTokens: number;
|
||||
usableBudgetTokens: number;
|
||||
maxInputTokens: number;
|
||||
modelMaxTokens?: number;
|
||||
triggerTokens: number;
|
||||
messagePairCount: number;
|
||||
@@ -277,13 +267,13 @@ function resolveBasicTargetTokens(input: {
|
||||
input.messagePairCount >= 5 &&
|
||||
typeof input.modelMaxTokens === "number" &&
|
||||
Number.isFinite(input.modelMaxTokens) &&
|
||||
input.modelMaxTokens < input.ceilingTokens
|
||||
? Math.floor(input.usableBudgetTokens * LONG_CONVERSATION_TARGET_RATIO)
|
||||
input.modelMaxTokens < input.maxInputTokens
|
||||
? Math.floor(input.maxInputTokens * LONG_CONVERSATION_TARGET_RATIO)
|
||||
: Math.floor(input.triggerTokens * DEFAULT_TARGET_RATIO);
|
||||
const triggerCeiling = Math.max(1, input.triggerTokens - 1);
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(targetTokens, input.usableBudgetTokens, triggerCeiling),
|
||||
Math.min(targetTokens, input.maxInputTokens, triggerCeiling),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,18 +348,22 @@ export function createContextCompactionPrepareTurn(
|
||||
(total: number, message) => total + estimateMessageTokens(message),
|
||||
0,
|
||||
);
|
||||
const compactionBudget = resolveCompactionBudget({
|
||||
const maxInputTokens = resolveMaxInputTokens({
|
||||
configMaxInputTokens: userCompaction?.maxInputTokens,
|
||||
modelMaxInputTokens: context.model.info?.maxInputTokens,
|
||||
contextWindow: context.model.info?.contextWindow,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
});
|
||||
|
||||
const triggerState = resolveTriggerState({
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
config: {
|
||||
maxInputTokens: userCompaction?.maxInputTokens,
|
||||
reserveTokens: userCompaction?.reserveTokens,
|
||||
thresholdRatio: userCompaction?.thresholdRatio,
|
||||
},
|
||||
modelMaxInputTokens: context.model.info?.maxInputTokens,
|
||||
contextWindow: context.model.info?.contextWindow,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
});
|
||||
const maxInputTokens = compactionBudget.usableBudgetTokens;
|
||||
const shouldCompact = inputTokens > compactionBudget.triggerTokens;
|
||||
config.logger?.debug("Context compaction diagnostics", {
|
||||
mode,
|
||||
strategy,
|
||||
@@ -378,19 +372,15 @@ export function createContextCompactionPrepareTurn(
|
||||
modelId: config.modelId,
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
ceilingTokens: compactionBudget.ceilingTokens,
|
||||
outputReserveTokens: compactionBudget.outputReserveTokens,
|
||||
ceilingSource: compactionBudget.ceilingSource,
|
||||
reserveCapped: compactionBudget.reserveCapped,
|
||||
triggerTokens: compactionBudget.triggerTokens,
|
||||
thresholdRatio: compactionBudget.thresholdRatio,
|
||||
shouldCompact,
|
||||
triggerTokens: triggerState.triggerTokens,
|
||||
thresholdRatio: triggerState.thresholdRatio,
|
||||
shouldCompact: triggerState.shouldCompact,
|
||||
messageCount: context.messages.length,
|
||||
apiMessageCount: context.apiMessages.length,
|
||||
apiMessagesJsonChars: safeJsonSize(context.apiMessages),
|
||||
...summarizeToolResults(context.apiMessages),
|
||||
});
|
||||
if (mode === "auto" && !shouldCompact) {
|
||||
if (mode === "auto" && !triggerState.shouldCompact) {
|
||||
return undefined;
|
||||
}
|
||||
const targetState =
|
||||
@@ -398,15 +388,14 @@ export function createContextCompactionPrepareTurn(
|
||||
? resolveManualTargetState({
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
autoTriggerTokens: compactionBudget.triggerTokens,
|
||||
autoTriggerTokens: triggerState.triggerTokens,
|
||||
manualTargetRatio: options.manualTargetRatio,
|
||||
})
|
||||
: compactionBudget;
|
||||
: triggerState;
|
||||
const targetTokens =
|
||||
mode === "auto"
|
||||
? resolveBasicTargetTokens({
|
||||
ceilingTokens: compactionBudget.ceilingTokens,
|
||||
usableBudgetTokens: compactionBudget.usableBudgetTokens,
|
||||
maxInputTokens,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
messagePairCount: countUserAssistantPairs(context.messages),
|
||||
@@ -437,9 +426,6 @@ export function createContextCompactionPrepareTurn(
|
||||
iteration: context.iteration,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
maxInputTokens,
|
||||
ceilingTokens: compactionBudget.ceilingTokens,
|
||||
outputReserveTokens: compactionBudget.outputReserveTokens,
|
||||
ceilingSource: compactionBudget.ceilingSource,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -481,10 +467,6 @@ export function createContextCompactionPrepareTurn(
|
||||
severity: "info",
|
||||
strategy: strategy,
|
||||
maxInputTokens,
|
||||
ceilingTokens: compactionBudget.ceilingTokens,
|
||||
outputReserveTokens: compactionBudget.outputReserveTokens,
|
||||
ceilingSource: compactionBudget.ceilingSource,
|
||||
reserveCapped: compactionBudget.reserveCapped,
|
||||
inputTokens,
|
||||
afterTokens,
|
||||
tokensSaved: inputTokens - afterTokens,
|
||||
|
||||
@@ -4,26 +4,6 @@ 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-"));
|
||||
@@ -100,99 +80,6 @@ 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,57 +71,32 @@ function createLineDiff(
|
||||
): string {
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
|
||||
// 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 max = Math.max(oldLines.length, newLines.length);
|
||||
const out: string[] = ["```diff"];
|
||||
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]}`);
|
||||
}
|
||||
let emitted = 0;
|
||||
|
||||
const omittedRemoved = removedCount - removedBudget;
|
||||
const omittedAdded = addedCount - addedBudget;
|
||||
if (omittedRemoved > 0 || omittedAdded > 0) {
|
||||
out.push(
|
||||
`... diff truncated (${omittedRemoved} more removed, ${omittedAdded} more added lines) ...`,
|
||||
);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
out.push("```");
|
||||
|
||||
@@ -27,8 +27,6 @@ export type DelegatedAgentConnectionConfig = Pick<
|
||||
| "providerConfig"
|
||||
| "knownModels"
|
||||
| "thinking"
|
||||
| "reasoningEffort"
|
||||
| "thinkingBudgetTokens"
|
||||
| "maxTokensPerTurn"
|
||||
>;
|
||||
|
||||
@@ -90,8 +88,6 @@ export function createDelegatedAgentConfigProvider(
|
||||
providerConfig: runtimeConfig.providerConfig,
|
||||
knownModels: runtimeConfig.knownModels,
|
||||
thinking: runtimeConfig.thinking,
|
||||
reasoningEffort: runtimeConfig.reasoningEffort,
|
||||
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
|
||||
maxTokensPerTurn: runtimeConfig.maxTokensPerTurn,
|
||||
}),
|
||||
updateConnectionDefaults: (overrides) => {
|
||||
|
||||
@@ -35,7 +35,6 @@ import type {
|
||||
RuntimeHostSubscribeOptions,
|
||||
SendSessionInput,
|
||||
SessionAccumulatedUsage,
|
||||
SessionConnectionUpdate,
|
||||
SessionUsageSummary,
|
||||
StartSessionInput,
|
||||
StartSessionResult,
|
||||
@@ -1291,27 +1290,6 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
return { updated: reply.ok };
|
||||
}
|
||||
|
||||
async updateSessionConnection(
|
||||
sessionId: string,
|
||||
updates: SessionConnectionUpdate,
|
||||
): Promise<void> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const reply = await this.client.command(
|
||||
"session.update_connection",
|
||||
{
|
||||
sessionId: target,
|
||||
updates,
|
||||
},
|
||||
target,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(hubReplyErrorMessage(reply, "session.update_connection"));
|
||||
}
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
|
||||
@@ -12,7 +12,6 @@ import { createSessionId } from "@cline/shared";
|
||||
import type {
|
||||
PendingPromptsRuntimeService,
|
||||
RuntimeHost,
|
||||
SessionConnectionRuntimeService,
|
||||
SessionUsageRuntimeService,
|
||||
} from "../../../runtime/host/runtime-host";
|
||||
import {
|
||||
@@ -53,11 +52,7 @@ export interface HubTransportContext {
|
||||
readonly suppressNextTerminalEventBySession: Map<string, string>;
|
||||
readonly telemetry?: ITelemetryService;
|
||||
readonly sessionHost: RuntimeHost &
|
||||
Partial<
|
||||
PendingPromptsRuntimeService &
|
||||
SessionUsageRuntimeService &
|
||||
SessionConnectionRuntimeService
|
||||
>;
|
||||
Partial<PendingPromptsRuntimeService & SessionUsageRuntimeService>;
|
||||
publish(event: HubEventEnvelope): void;
|
||||
buildEvent(
|
||||
event: HubEventEnvelope["event"],
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSessionConnectionUpdate } from "./session-handlers";
|
||||
|
||||
describe("readSessionConnectionUpdate", () => {
|
||||
it("enables thinking when a positive budget is supplied without thinking", () => {
|
||||
expect(readSessionConnectionUpdate({ thinkingBudgetTokens: 2048 })).toEqual(
|
||||
{
|
||||
thinking: true,
|
||||
thinkingBudgetTokens: 2048,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lets explicit thinking disable override reasoning fields", () => {
|
||||
const updates = readSessionConnectionUpdate({
|
||||
thinking: false,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 2048,
|
||||
});
|
||||
|
||||
expect(updates.thinking).toBe(false);
|
||||
expect(Object.hasOwn(updates, "reasoningEffort")).toBe(true);
|
||||
expect(updates.reasoningEffort).toBeUndefined();
|
||||
expect(Object.hasOwn(updates, "thinkingBudgetTokens")).toBe(true);
|
||||
expect(updates.thinkingBudgetTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,7 @@ import type {
|
||||
ToolApprovalRequest,
|
||||
} from "@cline/shared";
|
||||
import { createSessionId, parseRuntimeConfigExtensions } from "@cline/shared";
|
||||
import { normalizeConnectionUpdate } from "../../../runtime/config/connection-update";
|
||||
import type {
|
||||
RuntimeSessionConfig,
|
||||
SessionConnectionUpdate,
|
||||
} from "../../../runtime/host/runtime-host";
|
||||
import type { RuntimeSessionConfig } from "../../../runtime/host/runtime-host";
|
||||
import { parseSessionCompactionState } from "../../../session/models/session-compaction";
|
||||
import {
|
||||
SessionVersioningError,
|
||||
@@ -36,74 +32,6 @@ import {
|
||||
|
||||
const CAPABILITY_OWNER_METADATA_KEY = "hubCapabilityOwnerClientId";
|
||||
|
||||
function readConnectionString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readConnectionReasoningEffort(
|
||||
value: unknown,
|
||||
): SessionConnectionUpdate["reasoningEffort"] | undefined {
|
||||
if (
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh" ||
|
||||
value === null
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readSessionConnectionUpdate(
|
||||
value: unknown,
|
||||
): SessionConnectionUpdate {
|
||||
const record = asPlainRecord(value) ?? {};
|
||||
const updates: SessionConnectionUpdate = {};
|
||||
const providerId = readConnectionString(record.providerId);
|
||||
if (providerId) updates.providerId = providerId;
|
||||
const modelId = readConnectionString(record.modelId);
|
||||
if (modelId) updates.modelId = modelId;
|
||||
const apiKey = readConnectionString(record.apiKey);
|
||||
if (apiKey !== undefined) updates.apiKey = apiKey;
|
||||
const baseUrl = readConnectionString(record.baseUrl);
|
||||
if (baseUrl !== undefined) updates.baseUrl = baseUrl;
|
||||
if (record.headers && typeof record.headers === "object") {
|
||||
updates.headers = record.headers as Record<string, string>;
|
||||
}
|
||||
if (record.providerConfig && typeof record.providerConfig === "object") {
|
||||
updates.providerConfig =
|
||||
record.providerConfig as unknown as SessionConnectionUpdate["providerConfig"];
|
||||
}
|
||||
if (Object.hasOwn(record, "thinking")) {
|
||||
if (typeof record.thinking === "boolean" || record.thinking === null) {
|
||||
updates.thinking = record.thinking;
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(record, "reasoningEffort")) {
|
||||
const reasoningEffort = readConnectionReasoningEffort(
|
||||
record.reasoningEffort,
|
||||
);
|
||||
if (reasoningEffort !== undefined) {
|
||||
updates.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(record, "thinkingBudgetTokens")) {
|
||||
if (
|
||||
typeof record.thinkingBudgetTokens === "number" &&
|
||||
Number.isFinite(record.thinkingBudgetTokens) &&
|
||||
record.thinkingBudgetTokens > 0
|
||||
) {
|
||||
updates.thinkingBudgetTokens = Math.trunc(record.thinkingBudgetTokens);
|
||||
} else if (record.thinkingBudgetTokens === null) {
|
||||
updates.thinkingBudgetTokens = null;
|
||||
}
|
||||
}
|
||||
return normalizeConnectionUpdate(updates);
|
||||
}
|
||||
|
||||
function getCapabilityOwnerClientId(
|
||||
ctx: HubTransportContext,
|
||||
sessionId: string,
|
||||
@@ -892,32 +820,6 @@ export async function handleSessionUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionUpdateConnection(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
if (!sessionId) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_session_update_connection",
|
||||
"session.update_connection requires a session id",
|
||||
);
|
||||
}
|
||||
const updateSessionConnection = ctx.sessionHost.updateSessionConnection;
|
||||
if (!updateSessionConnection) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"unsupported_session_update_connection",
|
||||
"runtime host does not support session connection updates",
|
||||
);
|
||||
}
|
||||
const payload = asPlainRecord(envelope.payload);
|
||||
const updates = readSessionConnectionUpdate(payload?.updates);
|
||||
await updateSessionConnection.call(ctx.sessionHost, sessionId, updates);
|
||||
return okReply(envelope, { sessionId, updated: true });
|
||||
}
|
||||
|
||||
export async function handleSessionCompactionUpdate(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
|
||||
@@ -69,7 +69,6 @@ import {
|
||||
handleSessionRemovePendingPrompt,
|
||||
handleSessionRestore,
|
||||
handleSessionUpdate,
|
||||
handleSessionUpdateConnection,
|
||||
handleSessionUpdatePendingPrompt,
|
||||
} from "./handlers/session-handlers";
|
||||
import { eventNameForScheduleCommand } from "./hub-schedule-events";
|
||||
@@ -370,8 +369,6 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
return await handleSessionList(this.ctx, envelope);
|
||||
case "session.update":
|
||||
return await handleSessionUpdate(this.ctx, envelope);
|
||||
case "session.update_connection":
|
||||
return await handleSessionUpdateConnection(this.ctx, envelope);
|
||||
case "session.compaction.update":
|
||||
return await handleSessionCompactionUpdate(this.ctx, envelope);
|
||||
case "session.pending_prompts":
|
||||
|
||||
@@ -569,7 +569,6 @@ export {
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
loginLocalProvider,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
refreshProviderModelsFromSource,
|
||||
resolveLocalClineAuthToken,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { CoreSessionConfig } from "../../types/config";
|
||||
|
||||
export interface ConnectionUpdate {
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
providerConfig?: CoreSessionConfig["providerConfig"];
|
||||
reasoningEffort?: CoreSessionConfig["reasoningEffort"] | null;
|
||||
thinking?: CoreSessionConfig["thinking"] | null;
|
||||
thinkingBudgetTokens?: CoreSessionConfig["thinkingBudgetTokens"] | null;
|
||||
}
|
||||
|
||||
export function normalizeConnectionUpdate(
|
||||
updates: ConnectionUpdate,
|
||||
): ConnectionUpdate {
|
||||
const normalized: ConnectionUpdate = { ...updates };
|
||||
const hasThinking = Object.hasOwn(updates, "thinking");
|
||||
const hasThinkingBudgetTokens = Object.hasOwn(
|
||||
updates,
|
||||
"thinkingBudgetTokens",
|
||||
);
|
||||
const disablesThinking =
|
||||
updates.thinking === false || updates.thinking === null;
|
||||
|
||||
if (disablesThinking) {
|
||||
normalized.reasoningEffort = undefined;
|
||||
normalized.thinkingBudgetTokens = undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasThinking &&
|
||||
hasThinkingBudgetTokens &&
|
||||
typeof updates.thinkingBudgetTokens === "number" &&
|
||||
Number.isFinite(updates.thinkingBudgetTokens) &&
|
||||
updates.thinkingBudgetTokens > 0
|
||||
) {
|
||||
normalized.thinking = true;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -362,110 +362,6 @@ describe("LocalRuntimeHost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists thinking budget token connection updates", async () => {
|
||||
const sessionId = "sess-thinking-budget-update";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest,
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const runtimeBuilder = {
|
||||
build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }),
|
||||
};
|
||||
const agent = {
|
||||
run: vi.fn().mockResolvedValue(createResult()),
|
||||
continue: vi.fn().mockResolvedValue(createResult()),
|
||||
getMessages: vi.fn().mockReturnValue([]),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
updateConnection: vi.fn(),
|
||||
canStartRun: vi.fn().mockReturnValue(true),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const createAgent = vi.fn(() => agent as never);
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: runtimeBuilder as never,
|
||||
createAgent,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({
|
||||
sessionId,
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 1024,
|
||||
}),
|
||||
prompt: "hello",
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.updateSessionConnection(sessionId, {
|
||||
thinkingBudgetTokens: 2048,
|
||||
});
|
||||
|
||||
const getSessionOrThrow = Reflect.get(
|
||||
manager as object,
|
||||
"getSessionOrThrow",
|
||||
) as (sessionId: string) => { config: CoreSessionConfig };
|
||||
const session = Reflect.apply(getSessionOrThrow, manager, [sessionId]) as {
|
||||
config: CoreSessionConfig;
|
||||
};
|
||||
expect(session.config.thinking).toBe(true);
|
||||
expect(session.config.thinkingBudgetTokens).toBe(2048);
|
||||
expect(agent.updateConnection).toHaveBeenLastCalledWith({
|
||||
thinking: true,
|
||||
thinkingBudgetTokens: 2048,
|
||||
});
|
||||
|
||||
await manager.updateSessionConnection(sessionId, {
|
||||
thinking: false,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 4096,
|
||||
});
|
||||
|
||||
expect(session.config.thinking).toBe(false);
|
||||
expect(session.config.reasoningEffort).toBeUndefined();
|
||||
expect(session.config.thinkingBudgetTokens).toBeUndefined();
|
||||
expect(agent.updateConnection).toHaveBeenLastCalledWith({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
thinkingBudgetTokens: undefined,
|
||||
});
|
||||
|
||||
await manager.updateSessionConnection(sessionId, {
|
||||
thinking: null,
|
||||
reasoningEffort: null,
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
|
||||
expect(session.config.thinking).toBeUndefined();
|
||||
expect(session.config.reasoningEffort).toBeUndefined();
|
||||
expect(session.config.thinkingBudgetTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures active session lookup misses as handled telemetry", async () => {
|
||||
const adapter = {
|
||||
name: "test",
|
||||
@@ -4641,9 +4537,7 @@ describe("LocalRuntimeHost", () => {
|
||||
await expect(
|
||||
manager.updateSessionCompactionState(sessionId, incoming),
|
||||
).resolves.toEqual({ updated: false });
|
||||
expect(
|
||||
sessionService.persistSessionCompactionState,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ import type { ActiveSession, PreparedTurnInput } from "../../types/session";
|
||||
import type { SessionRecord } from "../../types/sessions";
|
||||
import type { RuntimeCapabilities } from "../capabilities";
|
||||
import { normalizeRuntimeCapabilities } from "../capabilities";
|
||||
import { normalizeConnectionUpdate } from "../config/connection-update";
|
||||
import { DefaultRuntimeBuilder } from "../orchestration/runtime-builder";
|
||||
import {
|
||||
OAuthReauthRequiredError,
|
||||
@@ -116,7 +115,6 @@ import type {
|
||||
RuntimeHostSubscribeOptions,
|
||||
SendSessionInput,
|
||||
SessionAccumulatedUsage,
|
||||
SessionConnectionUpdate,
|
||||
SessionUsageSummary,
|
||||
StartSessionInput,
|
||||
StartSessionResult,
|
||||
@@ -541,8 +539,8 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const agentConfig = {
|
||||
sessionId,
|
||||
@@ -556,7 +554,6 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
thinking: configWithProvider.thinking,
|
||||
reasoningEffort:
|
||||
configWithProvider.reasoningEffort ?? providerConfig.reasoningEffort,
|
||||
thinkingBudgetTokens: configWithProvider.thinkingBudgetTokens,
|
||||
maxTokensPerTurn: configWithProvider.maxTokensPerTurn,
|
||||
systemPrompt: configWithProvider.systemPrompt,
|
||||
maxIterations: configWithProvider.maxIterations,
|
||||
@@ -1232,72 +1229,12 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
}
|
||||
|
||||
async updateSessionModel(sessionId: string, modelId: string): Promise<void> {
|
||||
await this.updateSessionConnection(sessionId, { modelId });
|
||||
}
|
||||
|
||||
async updateSessionConnection(
|
||||
sessionId: string,
|
||||
rawUpdates: SessionConnectionUpdate,
|
||||
): Promise<void> {
|
||||
const updates = normalizeConnectionUpdate(rawUpdates);
|
||||
const session = this.getSessionOrThrow(sessionId);
|
||||
if (updates.providerId !== undefined)
|
||||
session.config.providerId = updates.providerId;
|
||||
if (updates.modelId !== undefined) session.config.modelId = updates.modelId;
|
||||
if (updates.apiKey !== undefined) session.config.apiKey = updates.apiKey;
|
||||
if (updates.baseUrl !== undefined) session.config.baseUrl = updates.baseUrl;
|
||||
if (updates.headers !== undefined) session.config.headers = updates.headers;
|
||||
if (updates.providerConfig !== undefined)
|
||||
session.config.providerConfig = updates.providerConfig;
|
||||
if (Object.hasOwn(updates, "reasoningEffort")) {
|
||||
session.config.reasoningEffort = updates.reasoningEffort ?? undefined;
|
||||
}
|
||||
if (Object.hasOwn(updates, "thinkingBudgetTokens")) {
|
||||
session.config.thinkingBudgetTokens =
|
||||
updates.thinkingBudgetTokens ?? undefined;
|
||||
}
|
||||
if (Object.hasOwn(updates, "thinking")) {
|
||||
session.config.thinking = updates.thinking ?? undefined;
|
||||
if (updates.thinking === false || updates.thinking === null) {
|
||||
session.config.reasoningEffort = undefined;
|
||||
session.config.thinkingBudgetTokens = undefined;
|
||||
}
|
||||
}
|
||||
const delegatedUpdates = {
|
||||
...(updates.providerId !== undefined
|
||||
? { providerId: updates.providerId }
|
||||
: {}),
|
||||
...(updates.modelId !== undefined ? { modelId: updates.modelId } : {}),
|
||||
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
|
||||
...(updates.baseUrl !== undefined ? { baseUrl: updates.baseUrl } : {}),
|
||||
...(updates.headers !== undefined ? { headers: updates.headers } : {}),
|
||||
...(updates.providerConfig !== undefined
|
||||
? { providerConfig: updates.providerConfig }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, "reasoningEffort")
|
||||
? { reasoningEffort: updates.reasoningEffort ?? undefined }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, "thinking")
|
||||
? { thinking: updates.thinking ?? undefined }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, "thinkingBudgetTokens")
|
||||
? { thinkingBudgetTokens: updates.thinkingBudgetTokens ?? undefined }
|
||||
: {}),
|
||||
};
|
||||
if (updates.thinking === false || updates.thinking === null) {
|
||||
delegatedUpdates.reasoningEffort = undefined;
|
||||
delegatedUpdates.thinkingBudgetTokens = undefined;
|
||||
}
|
||||
const teammateUpdates = {
|
||||
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
|
||||
...(updates.baseUrl !== undefined ? { baseUrl: updates.baseUrl } : {}),
|
||||
...(updates.headers !== undefined ? { headers: updates.headers } : {}),
|
||||
};
|
||||
session.runtime.delegatedAgentConfigProvider?.updateConnectionDefaults(
|
||||
delegatedUpdates,
|
||||
);
|
||||
session.agent.updateConnection(updates);
|
||||
session.runtime.teamRuntime?.updateTeammateConnections(teammateUpdates);
|
||||
session.config.modelId = modelId;
|
||||
session.runtime.delegatedAgentConfigProvider?.updateConnectionDefaults({
|
||||
modelId,
|
||||
});
|
||||
session.agent.updateConnection({ modelId });
|
||||
}
|
||||
|
||||
// Retained for unit tests that reach in via Reflect.
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
} from "../../types/events";
|
||||
import type { SessionRecord } from "../../types/sessions";
|
||||
import type { RuntimeCapabilities } from "../capabilities";
|
||||
import type { ConnectionUpdate } from "../config/connection-update";
|
||||
|
||||
export const SESSION_NOT_FOUND_ERROR_CODE = "session_not_found";
|
||||
|
||||
@@ -260,19 +259,10 @@ export interface SessionUsageRuntimeService {
|
||||
): Promise<SessionUsageSummary | undefined>;
|
||||
}
|
||||
|
||||
export type SessionConnectionUpdate = ConnectionUpdate;
|
||||
|
||||
export interface SessionModelRuntimeService {
|
||||
updateSessionModel(sessionId: string, modelId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SessionConnectionRuntimeService {
|
||||
updateSessionConnection(
|
||||
sessionId: string,
|
||||
updates: SessionConnectionUpdate,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimeHostSubscribeOptions {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
@@ -485,8 +485,6 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
thinkingBudgetTokens: config.thinkingBudgetTokens,
|
||||
maxTokensPerTurn: config.maxTokensPerTurn,
|
||||
maxIterations: config.maxIterations,
|
||||
hooks,
|
||||
|
||||
@@ -1210,55 +1210,6 @@ describe("SessionRuntime.addTools / updateConnection / clearHistory / restore",
|
||||
expect(calls.run).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updateConnection clears stale reasoning fields for next run", async () => {
|
||||
const { deps, configs } = withCapturingFakeRuntime();
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 1024,
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
session.updateConnection({
|
||||
thinking: null,
|
||||
reasoningEffort: null,
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
await session.run("go");
|
||||
expect(configs[0]?.modelOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updateConnection lets explicit thinking disable override a simultaneous budget", async () => {
|
||||
const { deps, configs } = withCapturingFakeRuntime();
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 1024,
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
session.updateConnection({
|
||||
thinking: false,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 2048,
|
||||
});
|
||||
await session.run("go");
|
||||
expect(configs[0]?.modelOptions).toEqual({ thinking: false });
|
||||
});
|
||||
|
||||
it("updateConnection enables thinking when a positive budget is supplied", async () => {
|
||||
const { deps, configs } = withCapturingFakeRuntime();
|
||||
const session = new SessionRuntime(makeAgentConfig(), deps);
|
||||
session.updateConnection({ thinkingBudgetTokens: 2048 });
|
||||
await session.run("go");
|
||||
expect(configs[0]?.modelOptions).toEqual({
|
||||
thinking: true,
|
||||
thinkingBudgetTokens: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it("clearHistory resets the conversation store", () => {
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({
|
||||
|
||||
@@ -64,10 +64,6 @@ import {
|
||||
messagesToAgentMessages,
|
||||
} from "../config/agent-message-codec";
|
||||
import { createAgentRuntimeConfig } from "../config/agent-runtime-config-builder";
|
||||
import {
|
||||
type ConnectionUpdate,
|
||||
normalizeConnectionUpdate,
|
||||
} from "../config/connection-update";
|
||||
import { LoopDetectionTracker } from "../safety/loop-detection";
|
||||
import { MistakeTracker } from "../safety/mistake-tracker";
|
||||
import { RuntimeEventAdapter } from "./runtime-event-adapter";
|
||||
@@ -260,7 +256,17 @@ export interface SessionRuntimeOrchestratorDeps {
|
||||
}
|
||||
|
||||
/** Connection overrides applied via `updateConnection`. */
|
||||
export type ConnectionOverrides = ConnectionUpdate;
|
||||
export interface ConnectionOverrides {
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
providerConfig?: unknown;
|
||||
reasoningEffort?: AgentConfig["reasoningEffort"];
|
||||
thinking?: boolean;
|
||||
thinkingBudgetTokens?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SessionRuntime orchestrator
|
||||
@@ -479,28 +485,20 @@ export class SessionRuntime {
|
||||
|
||||
/** Mutate provider / reasoning fields for subsequent runs. */
|
||||
updateConnection(overrides: ConnectionOverrides): void {
|
||||
const updates = normalizeConnectionUpdate(overrides);
|
||||
const next: AgentConfig = { ...this.config };
|
||||
if (updates.providerId !== undefined) next.providerId = updates.providerId;
|
||||
if (updates.modelId !== undefined) next.modelId = updates.modelId;
|
||||
if (updates.apiKey !== undefined) next.apiKey = updates.apiKey;
|
||||
if (updates.baseUrl !== undefined) next.baseUrl = updates.baseUrl;
|
||||
if (updates.headers !== undefined) next.headers = updates.headers;
|
||||
if (updates.providerConfig !== undefined)
|
||||
next.providerConfig = updates.providerConfig;
|
||||
if (Object.hasOwn(updates, "reasoningEffort")) {
|
||||
next.reasoningEffort = updates.reasoningEffort ?? undefined;
|
||||
}
|
||||
if (Object.hasOwn(updates, "thinkingBudgetTokens")) {
|
||||
next.thinkingBudgetTokens = updates.thinkingBudgetTokens ?? undefined;
|
||||
}
|
||||
if (Object.hasOwn(updates, "thinking")) {
|
||||
next.thinking = updates.thinking ?? undefined;
|
||||
if (updates.thinking === false || updates.thinking === null) {
|
||||
next.reasoningEffort = undefined;
|
||||
next.thinkingBudgetTokens = undefined;
|
||||
}
|
||||
}
|
||||
if (overrides.providerId !== undefined)
|
||||
next.providerId = overrides.providerId;
|
||||
if (overrides.modelId !== undefined) next.modelId = overrides.modelId;
|
||||
if (overrides.apiKey !== undefined) next.apiKey = overrides.apiKey;
|
||||
if (overrides.baseUrl !== undefined) next.baseUrl = overrides.baseUrl;
|
||||
if (overrides.headers !== undefined) next.headers = overrides.headers;
|
||||
if (overrides.providerConfig !== undefined)
|
||||
next.providerConfig = overrides.providerConfig;
|
||||
if (overrides.reasoningEffort !== undefined)
|
||||
next.reasoningEffort = overrides.reasoningEffort;
|
||||
if (overrides.thinking !== undefined) next.thinking = overrides.thinking;
|
||||
if (overrides.thinkingBudgetTokens !== undefined)
|
||||
next.thinkingBudgetTokens = overrides.thinkingBudgetTokens;
|
||||
this.config = next;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,8 +216,8 @@ describe("resolveProviderConfig", () => {
|
||||
family: "gpt",
|
||||
release_date: "2027-01-01",
|
||||
},
|
||||
"gpt-5.3-live": {
|
||||
name: "GPT-5.3 Live",
|
||||
"gpt-5.4-live": {
|
||||
name: "GPT-5.4 Live",
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
family: "gpt",
|
||||
@@ -256,7 +256,7 @@ describe("resolveProviderConfig", () => {
|
||||
});
|
||||
|
||||
expect(resolved?.knownModels?.["gpt-5.6-live"]?.name).toBe("GPT-5.6 Live");
|
||||
expect(resolved?.knownModels?.["gpt-5.3-live"]).toBeUndefined();
|
||||
expect(resolved?.knownModels?.["gpt-5.4-live"]).toBeUndefined();
|
||||
expect(resolved?.knownModels?.["gpt-5.4-nano"]).toBeUndefined();
|
||||
expect(resolved?.knownModels?.["o-live"]).toBeUndefined();
|
||||
});
|
||||
@@ -481,8 +481,9 @@ describe("resolveProviderConfig", () => {
|
||||
const openAiResolved = await resolveProviderConfig("openai-native");
|
||||
const modelIds = Object.keys(resolved?.knownModels ?? {});
|
||||
|
||||
expect(modelIds).toEqual(expect.arrayContaining(["gpt-5.5", "gpt-5.4"]));
|
||||
expect(modelIds).not.toContain("gpt-5.5-pro");
|
||||
expect(modelIds).toEqual(
|
||||
expect.arrayContaining(["gpt-5.5", "gpt-5.5-pro", "gpt-5.4"]),
|
||||
);
|
||||
expect(modelIds).not.toContain("gpt-5.1-codex-max");
|
||||
expect(modelIds).not.toContain("gpt-5.2-codex");
|
||||
expect(modelIds).not.toContain("gpt-5.4-nano");
|
||||
@@ -491,10 +492,8 @@ describe("resolveProviderConfig", () => {
|
||||
expect(resolved?.knownModels?.["gpt-5.5"]).toEqual(
|
||||
expect.objectContaining({
|
||||
...openAiResolved?.knownModels?.["gpt-5.5"],
|
||||
// ChatGPT/Codex backend caps: 272K input at the 95% effective budget
|
||||
maxInputTokens: 272_000 * 0.95,
|
||||
maxInputTokens: 272_000,
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -517,8 +516,7 @@ describe("resolveProviderConfig", () => {
|
||||
expect(resolved?.knownModels?.["gpt-5.4-mini"]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "GPT-5.4 mini",
|
||||
// catalog input cap scaled to the 95% effective Codex budget
|
||||
maxInputTokens: 272_000 * 0.95,
|
||||
maxInputTokens: 272_000,
|
||||
contextWindow: 400_000,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
deleteLocalProvider,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
refreshProviderModelsFromSource,
|
||||
resolveLocalClineAuthToken,
|
||||
@@ -1238,51 +1237,6 @@ describe("listLocalProviders", () => {
|
||||
expect(p?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("marks alias providers enabled without copying shared OAuth credentials", async () => {
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "shared-token",
|
||||
refreshToken: "shared-refresh",
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
);
|
||||
|
||||
markLocalProviderEnabled(manager, "cline-pass", { tokenSource: "oauth" });
|
||||
|
||||
const state = manager.read();
|
||||
expect(state.providers["cline-pass"]?.settings).toEqual({
|
||||
provider: "cline-pass",
|
||||
});
|
||||
expect(state.providers["cline-pass"]?.tokenSource).toBe("oauth");
|
||||
});
|
||||
|
||||
it("resolves shared OAuth metadata for ClinePass catalog entries", async () => {
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "shared-token",
|
||||
refreshToken: "shared-refresh",
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
);
|
||||
markLocalProviderEnabled(manager, "cline-pass", { tokenSource: "oauth" });
|
||||
|
||||
const { providers } = await listLocalProviders(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const clinePass = providers.find((provider) => provider.id === "cline-pass");
|
||||
|
||||
expect(clinePass).toMatchObject({
|
||||
enabled: true,
|
||||
oauthAccessTokenPresent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes model count", async () => {
|
||||
await addLocalProvider(manager, {
|
||||
providerId: "count-provider",
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
ProviderProtocol,
|
||||
ProviderSettings,
|
||||
} from "../../services/llms/provider-settings";
|
||||
import type { ProviderTokenSource } from "../../types/provider-settings";
|
||||
import type { ProviderSettingsManager } from "../storage/provider-settings-manager";
|
||||
import {
|
||||
readModelsFile,
|
||||
@@ -653,26 +652,6 @@ export async function deleteLocalProvider(
|
||||
};
|
||||
}
|
||||
|
||||
export function markLocalProviderEnabled(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
options: { tokenSource?: ProviderTokenSource } = {},
|
||||
): { providerId: string; enabled: true; settingsPath: string } {
|
||||
const id = providerId.trim();
|
||||
if (!id) throw new Error("providerId is required");
|
||||
|
||||
const directSettings = manager.read().providers[id]?.settings;
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...(directSettings ?? {}),
|
||||
provider: id,
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: options.tokenSource },
|
||||
);
|
||||
|
||||
return { providerId: id, enabled: true, settingsPath: manager.getFilePath() };
|
||||
}
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
options: ListLocalProvidersOptions = {},
|
||||
@@ -688,8 +667,7 @@ export async function listLocalProviders(
|
||||
LlmsModels.getModelsForProvider(id),
|
||||
]);
|
||||
const modelList = toSortedProviderModels(registeredModels);
|
||||
const directSettings = state.providers[id]?.settings;
|
||||
const persistedSettings = manager.getProviderSettings(id);
|
||||
const persistedSettings = state.providers[id]?.settings;
|
||||
const name = info?.name ?? titleCaseFromId(id);
|
||||
const capabilities = resolveProviderCapabilities(
|
||||
info?.capabilities,
|
||||
@@ -705,7 +683,7 @@ export async function listLocalProviders(
|
||||
models: modelList.length,
|
||||
color: stableColor(id),
|
||||
letter: createLetter(name),
|
||||
enabled: Boolean(directSettings),
|
||||
enabled: Boolean(persistedSettings),
|
||||
apiKey: persistedSettings
|
||||
? resolveVisibleApiKey(persistedSettings)
|
||||
: undefined,
|
||||
|
||||
@@ -37,10 +37,6 @@ export interface CoreModelConfig {
|
||||
* Explicit reasoning effort override for capable models.
|
||||
*/
|
||||
reasoningEffort?: ProviderConfig["reasoningEffort"];
|
||||
/**
|
||||
* Explicit thinking/reasoning token budget for capable models.
|
||||
*/
|
||||
thinkingBudgetTokens?: number;
|
||||
/**
|
||||
* Maximum output tokens per API call.
|
||||
*/
|
||||
@@ -66,17 +62,9 @@ export interface CoreCompactionContext {
|
||||
provider: string;
|
||||
info?: ModelInfo;
|
||||
};
|
||||
/**
|
||||
* Usable input budget after reserving any shared context window space needed
|
||||
* for model output.
|
||||
*/
|
||||
maxInputTokens: number;
|
||||
triggerTokens: number;
|
||||
targetTokens?: number;
|
||||
/**
|
||||
* Effective trigger point as a fraction of the usable input budget
|
||||
* (`triggerTokens / maxInputTokens`).
|
||||
*/
|
||||
thresholdRatio: number;
|
||||
utilizationRatio: number;
|
||||
}
|
||||
@@ -91,6 +79,13 @@ export interface CoreCompactionSummarizerConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Optional pre-resolved model metadata for the summarizer. Supplying either
|
||||
* this or `knownModels` lets agentic compaction budget summary input against
|
||||
* the summarizer model's actual context window instead of falling back to the
|
||||
* active model's window.
|
||||
*/
|
||||
modelInfo?: ModelInfo;
|
||||
knownModels?: Record<string, ModelInfo>;
|
||||
providerConfig?: ProviderConfig;
|
||||
maxOutputTokens?: number;
|
||||
|
||||
@@ -7,7 +7,6 @@ export type {
|
||||
ProviderInfo,
|
||||
} from "./models";
|
||||
export {
|
||||
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
filterOpenAICodexModels,
|
||||
getAllProviders,
|
||||
getGeneratedModelsForProvider,
|
||||
|
||||
@@ -9,7 +9,6 @@ export type {
|
||||
ProviderProtocol,
|
||||
} from "./models";
|
||||
export {
|
||||
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
fetchLiveProviderModels,
|
||||
fetchModelsDevProviderModels,
|
||||
filterOpenAICodexModels,
|
||||
|
||||
@@ -35,7 +35,4 @@ export {
|
||||
resetRegistry,
|
||||
unregisterProvider,
|
||||
} from "./providers/model-registry";
|
||||
export {
|
||||
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
filterOpenAICodexModels,
|
||||
} from "./providers/openai-codex-models";
|
||||
export { filterOpenAICodexModels } from "./providers/openai-codex-models";
|
||||
|
||||
@@ -120,9 +120,13 @@ describe("built-in provider metadata", () => {
|
||||
const modelIds = Object.keys(chatGptModels);
|
||||
|
||||
expect(modelIds).toEqual(
|
||||
expect.arrayContaining(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]),
|
||||
expect.arrayContaining([
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
]),
|
||||
);
|
||||
expect(modelIds).not.toContain("gpt-5.5-pro");
|
||||
expect(modelIds).not.toContain("gpt-5.1-codex-max");
|
||||
expect(modelIds).not.toContain("gpt-5.2");
|
||||
expect(modelIds).not.toContain("gpt-5.2-codex");
|
||||
@@ -133,10 +137,8 @@ describe("built-in provider metadata", () => {
|
||||
expect(chatGptModels["gpt-5.5"]).toEqual(
|
||||
expect.objectContaining({
|
||||
...openAiModels["gpt-5.5"],
|
||||
// ChatGPT/Codex backend caps: 272K input at the 95% effective budget
|
||||
maxInputTokens: 272_000 * 0.95,
|
||||
maxInputTokens: 272_000,
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
}),
|
||||
);
|
||||
expect(chatGptModels["gpt-5.4"]).toEqual(
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelInfo } from "../catalog/types";
|
||||
import {
|
||||
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
filterOpenAICodexModels,
|
||||
} from "./openai-codex-models";
|
||||
|
||||
function makeModel(id: string, overrides: Partial<ModelInfo> = {}): ModelInfo {
|
||||
return {
|
||||
id,
|
||||
contextWindow: 400_000,
|
||||
maxInputTokens: 300_000,
|
||||
maxTokens: 100_000,
|
||||
family: id.replace(/^gpt-/, "gpt"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function filterOne(
|
||||
id: string,
|
||||
overrides: Partial<ModelInfo> = {},
|
||||
): ModelInfo | undefined {
|
||||
return filterOpenAICodexModels({ [id]: makeModel(id, overrides) })[id];
|
||||
}
|
||||
|
||||
describe("filterOpenAICodexModels", () => {
|
||||
describe("model eligibility", () => {
|
||||
it.each([
|
||||
["gpt-5.4", true],
|
||||
["gpt-5.5", true],
|
||||
["gpt-5.5-codex", true],
|
||||
["gpt-6.0", true],
|
||||
["gpt-10.1", true],
|
||||
])("allows %s (newer than 5.3)", (id, allowed) => {
|
||||
expect(filterOne(id) !== undefined).toBe(allowed);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["gpt-5.3", "at the 5.3 cutoff"],
|
||||
["gpt-5.1", "older than 5.3"],
|
||||
["gpt-4.1", "older major version"],
|
||||
["gpt-5", "no minor version"],
|
||||
["chatgpt-5.5", "id does not start with gpt-"],
|
||||
["davinci", "not a gpt model"],
|
||||
])("rejects %s (%s)", (id) => {
|
||||
expect(filterOne(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["o-series", "o4"],
|
||||
["pro variant", "gpt5.5-pro"],
|
||||
["nano variant", "gpt5.5-nano"],
|
||||
])("rejects %s families regardless of id version", (_label, family) => {
|
||||
expect(filterOne("gpt-6.0", { family })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the id version check when family is missing", () => {
|
||||
expect(filterOne("gpt-6.0", { family: undefined })).toBeDefined();
|
||||
expect(filterOne("gpt-5.0", { family: undefined })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window adjustment", () => {
|
||||
it.each(["gpt-5.4", "gpt-5.4-mini", "gpt-6.0"])(
|
||||
"scales %s maxInputTokens down to the effective Codex budget — the backend cap applies to every model, not just gpt-5.5",
|
||||
(id) => {
|
||||
const maxInputTokens = 200_000;
|
||||
const result = filterOne(id, { maxInputTokens });
|
||||
expect(result?.maxInputTokens).toBe(
|
||||
maxInputTokens * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves other limits untouched for non-5.5 models", () => {
|
||||
const result = filterOne("gpt-6.0", {
|
||||
contextWindow: 500_000,
|
||||
maxTokens: 64_000,
|
||||
});
|
||||
expect(result?.contextWindow).toBe(500_000);
|
||||
expect(result?.maxTokens).toBe(64_000);
|
||||
});
|
||||
|
||||
it("preserves an undefined maxInputTokens instead of producing NaN", () => {
|
||||
const result = filterOne("gpt-6.0", { maxInputTokens: undefined });
|
||||
expect(result?.maxInputTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("overrides gpt-5.5 limits with the ChatGPT backend caps", () => {
|
||||
const result = filterOne("gpt-5.5-codex", {
|
||||
contextWindow: 1_000_000,
|
||||
maxInputTokens: 900_000,
|
||||
maxTokens: 900_000,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
contextWindow: 400_000,
|
||||
maxInputTokens: 272_000 * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input models", () => {
|
||||
const model = makeModel("gpt-6.0");
|
||||
const snapshot = structuredClone(model);
|
||||
filterOpenAICodexModels({ "gpt-6.0": model });
|
||||
expect(model).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps allowed models and drops disallowed ones from a mixed catalog", () => {
|
||||
const models: Record<string, ModelInfo> = {
|
||||
"gpt-5.5": makeModel("gpt-5.5"),
|
||||
"gpt-6.0": makeModel("gpt-6.0"),
|
||||
"gpt-5.1": makeModel("gpt-5.1"),
|
||||
"o4-mini": makeModel("o4-mini", { family: "o4" }),
|
||||
};
|
||||
expect(Object.keys(filterOpenAICodexModels(models)).sort()).toEqual([
|
||||
"gpt-5.5",
|
||||
"gpt-6.0",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,35 @@
|
||||
import type { ModelInfo } from "../catalog/types";
|
||||
|
||||
/**
|
||||
* The ChatGPT/Codex backend starts rejecting requests around 95% of a
|
||||
* model's advertised input cap, so every model exposed through this
|
||||
* provider gets its maxInputTokens scaled down to the effective budget.
|
||||
*
|
||||
* REF: https://github.com/openai/codex/issues/19319
|
||||
*/
|
||||
export const CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 0.95;
|
||||
const OPENAI_CODEX_ALLOWED_MODELS = new Set([
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
]);
|
||||
|
||||
const GPT_VERSION_REGEX = /^gpt-(\d+\.\d+)/;
|
||||
|
||||
function isOpenAICodexAllowedModel(id: string, model: ModelInfo): boolean {
|
||||
// O, pro, and nano variants are not supported
|
||||
const family = model.family;
|
||||
if (
|
||||
family &&
|
||||
(family.startsWith("o") ||
|
||||
family.includes("pro") ||
|
||||
family.includes("nano"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Must be newer than 5.3
|
||||
const match = id.match(GPT_VERSION_REGEX);
|
||||
return match ? Number.parseFloat(match[1]) > 5.3 : false;
|
||||
function isOpenAICodexAllowedModel(id: string): boolean {
|
||||
if (OPENAI_CODEX_ALLOWED_MODELS.has(id)) return true;
|
||||
const match = id.match(/^gpt-(\d+\.\d+)/);
|
||||
return match ? Number.parseFloat(match[1]) > 5.4 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the effective input budget to every allowed model. GPT-5.5
|
||||
* additionally gets hardcoded limits because the ChatGPT/Codex backend
|
||||
* enforces a 272K input / 128K output cap that is lower than what the
|
||||
* generated OpenAI API catalog reports.
|
||||
*/
|
||||
function toOpenAICodexModel(id: string, model: ModelInfo): ModelInfo {
|
||||
if (id.includes("gpt-5.5")) {
|
||||
return {
|
||||
...model,
|
||||
contextWindow: 400_000,
|
||||
maxInputTokens: 272_000 * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
if (!id.includes("gpt-5.5")) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
maxInputTokens: model.maxInputTokens
|
||||
? model.maxInputTokens * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT
|
||||
: model.maxInputTokens,
|
||||
contextWindow: 400_000,
|
||||
maxInputTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterOpenAICodexModels(
|
||||
models: Record<string, ModelInfo>,
|
||||
): Record<string, ModelInfo> {
|
||||
const result: Record<string, ModelInfo> = {};
|
||||
for (const [id, model] of Object.entries(models)) {
|
||||
if (isOpenAICodexAllowedModel(id, model)) {
|
||||
result[id] = toOpenAICodexModel(id, model);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return Object.fromEntries(
|
||||
Object.entries(models)
|
||||
.filter(([id]) => isOpenAICodexAllowedModel(id))
|
||||
.map(([id, model]) => [id, toOpenAICodexModel(id, model)]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -384,7 +384,6 @@ export type HubCommandName =
|
||||
| "session.restore"
|
||||
| "session.delete"
|
||||
| "session.update"
|
||||
| "session.update_connection"
|
||||
| "session.compaction.get"
|
||||
| "session.compaction.update"
|
||||
| "session.pending_prompts"
|
||||
@@ -666,7 +665,6 @@ export interface HubSessionRuntimeOptions {
|
||||
timeoutSeconds?: number;
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
thinkingBudgetTokens?: number;
|
||||
checkpointEnabled?: boolean;
|
||||
enableTools?: boolean;
|
||||
enableSpawn?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user