mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3b80b223d | |||
| d0e4f33a88 | |||
| 0df406723c | |||
| 12703bf407 | |||
| 4a97b46f5f | |||
| 36fc3327ac | |||
| 2b48dc411f | |||
| da6fe718d0 | |||
| 3515333e23 | |||
| bd9ac5872b | |||
| fb15324ad2 | |||
| 7a27c04ffa | |||
| c37b252f65 | |||
| 2872138900 | |||
| dc4620c529 | |||
| 2ac5c85e69 | |||
| ab68fd7f34 | |||
| b4ed8a226e | |||
| cbf40961db | |||
| 2d05ba52da | |||
| 5d3778b5cf | |||
| 8d0eb54a1f | |||
| a3989acc38 | |||
| d199b1bff9 | |||
| d41eed1198 | |||
| 6309971089 | |||
| 2d2c669421 | |||
| c5f146a418 | |||
| 261ee4c313 | |||
| d45b051c04 | |||
| 78c83cdf33 | |||
| 3266121fa1 | |||
| 6467de65a2 | |||
| 65fe885638 | |||
| c3033d6f13 | |||
| cfb1327a1b | |||
| 264af96e1b | |||
| 10cb9bd97a | |||
| 2ee18e7f0c | |||
| 0b65506a2b | |||
| 3502608081 | |||
| ed3107f9ec | |||
| 6bce48aad4 | |||
| 1e1b6af51c | |||
| ee49900232 | |||
| a1d5589d19 | |||
| 721fda2e99 | |||
| 5e78861eb5 | |||
| 29798f59f3 | |||
| 177d0eb07f | |||
| 869a87a220 | |||
| 0cfd0bbe05 | |||
| 08f656532f | |||
| 10dece6677 | |||
| 885a2936b6 | |||
| 90c427740d | |||
| e6028168f2 | |||
| c3f75b3ff0 | |||
| 88ce3e0b11 | |||
| dd719dce86 |
@@ -126,7 +126,7 @@ jobs:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
VSCODE_TEST_VERSION: 1.101.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -260,6 +260,41 @@ jobs:
|
||||
git push origin "refs/tags/${TAG}"
|
||||
done
|
||||
|
||||
- name: Get Previous SDK Tag
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: prev_tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# The checkout is shallow and tagless, so fetch the release tags explicitly.
|
||||
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
|
||||
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
|
||||
DELIMITER=$(openssl rand -hex 8)
|
||||
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
|
||||
name: "SDK v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -280,3 +315,26 @@ jobs:
|
||||
echo " - sdk/core/v${VERSION}"
|
||||
echo " - sdk/sdk/v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Post release to Slack
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
|
||||
|
||||
@@ -85,3 +85,10 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
|
||||
@@ -39,14 +39,6 @@
|
||||
"sdk/packages/core/src/auth/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-telemetry-doc-update",
|
||||
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
|
||||
"scope": [
|
||||
"sdk/packages/core/src/services/telemetry/core-events.ts"
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,13 +16,9 @@
|
||||
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
|
||||
},
|
||||
{
|
||||
"path": "DOC.md",
|
||||
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
|
||||
},
|
||||
{
|
||||
"path": "sdk/ARCHITECTURE.md",
|
||||
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "sdk/AGENTS.md",
|
||||
|
||||
+22
-17
@@ -36,8 +36,13 @@ event names. It exports:
|
||||
|
||||
1. Add the constant to `CORE_TELEMETRY_EVENTS`
|
||||
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
|
||||
3. Update the Event Catalog section in `DOC.md`
|
||||
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
|
||||
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
|
||||
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
|
||||
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
|
||||
**All events should be named using snake_case and so should their properties**
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
@@ -82,7 +87,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
|
||||
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
|
||||
config dir.
|
||||
|
||||
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
|
||||
The canonical pattern is in `apps/cli/src/main.ts`:
|
||||
|
||||
```ts
|
||||
if (configDir) setClineDir(configDir);
|
||||
@@ -90,18 +95,18 @@ setHomeDir(homedir());
|
||||
captureCliExtensionActivated(); // <-- after dir overrides
|
||||
```
|
||||
|
||||
## Hub Daemon Metadata Forwarding
|
||||
## Hub Daemon Telemetry
|
||||
|
||||
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
|
||||
metadata into the daemon argv so the daemon can reconstruct an equivalent
|
||||
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
|
||||
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
|
||||
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
|
||||
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
|
||||
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
|
||||
```
|
||||
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
|
||||
```
|
||||
|
||||
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
|
||||
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
@@ -120,10 +125,10 @@ canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
|
||||
On VS Code, the telemetry handle is built **once** in `activate()`
|
||||
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
|
||||
command, and daemon spawn payload. Do not let individual controllers construct their own
|
||||
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
On VS Code, all callers go through the lazy `telemetryService` proxy in
|
||||
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
|
||||
use. Do not let individual controllers construct their own `ITelemetryService` — that
|
||||
fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
|
||||
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
|
||||
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
|
||||
|
||||
Vendored
+7
-7
@@ -68,7 +68,7 @@
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -89,7 +89,7 @@
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -114,16 +114,16 @@
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
"message": 1
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
- Fixed provider config not reloading when switching models
|
||||
- Fixed auto-update failing to detect Bun global installs after symlink resolution
|
||||
- Fixed unexpected logouts caused by transient network or server errors during token refresh
|
||||
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
|
||||
- Session id is now preserved when continuing within the same session
|
||||
- Hardened context compaction budget handling
|
||||
|
||||
## 3.0.39
|
||||
|
||||
- You can now select Cline free models on the ClinePass provider in the model picker
|
||||
- Removed the retired ClinePass GLM 5.1 model
|
||||
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
|
||||
- `str_replace` edits now report accurate diffs
|
||||
- Fixed context compaction so canonical session history is preserved
|
||||
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
|
||||
- Cline provider requests now send versioned client-identity headers
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.38",
|
||||
"version": "3.0.40",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -511,6 +511,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
const cwd = session.cwd || process.cwd();
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
// Resolve credentials: env vars take precedence, then session provider.
|
||||
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
|
||||
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
@@ -519,6 +520,7 @@ export class AcpAgent implements Agent {
|
||||
providerId,
|
||||
mode: session.currentMode,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
|
||||
return {
|
||||
providerId,
|
||||
@@ -537,7 +539,23 @@ export class AcpAgent implements Agent {
|
||||
enableAgentTeams: false,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot: resolveWorkspaceRoot(cwd),
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: {
|
||||
name: "cline-acp",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
workspaceName: cwd,
|
||||
ide: "Terminal Shell",
|
||||
platform: process.platform,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,22 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
@@ -118,7 +118,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
|
||||
// them to ~/.bun/install/global/node_modules/..., so match both.
|
||||
if (
|
||||
scriptPath.includes("/.bun/bin") ||
|
||||
scriptPath.includes("/.bun/install/global/")
|
||||
) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
|
||||
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
});
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
@@ -1013,6 +1014,80 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
|
||||
// CLINE-2406: when persisted Cline auth includes an accountId, the
|
||||
// runtime path must call identifyTelemetryAccount(accountContext) so
|
||||
// subsequent task.* and workspace.* events carry user_id.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "usr-abc-123",
|
||||
provider: "cline",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
|
||||
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
|
||||
// identifyTelemetryAccount should not be called from the runtime path.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
// no auth / no accountId
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
|
||||
// CLINE-2406: identity identification from saved settings only applies
|
||||
// to Cline-provider sessions; other providers use different auth flows.
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "openrouter",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs kanban before loading runtime modules", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "kanban"];
|
||||
|
||||
|
||||
+34
-1
@@ -15,6 +15,7 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
@@ -46,6 +47,7 @@ import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
getCliTelemetryService,
|
||||
identifyTelemetryAccount,
|
||||
} from "./utils/telemetry";
|
||||
import type { Config } from "./utils/types";
|
||||
import { runConnectWizard } from "./wizards/connect";
|
||||
@@ -926,6 +928,17 @@ export async function runCli(): Promise<void> {
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// Register the SDK early logger as early as possible — before any
|
||||
// provider settings reads — so the full startup sequence is captured.
|
||||
// These components operate before/outside ClineCore sessions, so the
|
||||
// session-scoped logger can't reach them.
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "main",
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
@@ -962,6 +975,19 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
let selectedProviderSettings =
|
||||
providerSettingsManager.getProviderSettings(provider);
|
||||
|
||||
// Apply locally persisted Cline account identity so subsequent events
|
||||
// (task.*, workspace.initialized) carry user_id when available.
|
||||
// Note: user.extension_activated fires anonymously earlier in startup
|
||||
// and cannot be retroactively updated; this is by design for
|
||||
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
|
||||
if (provider === "cline") {
|
||||
const savedAccountId = selectedProviderSettings?.auth?.accountId;
|
||||
if (savedAccountId) {
|
||||
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
|
||||
}
|
||||
}
|
||||
|
||||
const persistedApiKey = getPersistedProviderApiKey(
|
||||
provider,
|
||||
selectedProviderSettings,
|
||||
@@ -1029,6 +1055,7 @@ export async function runCli(): Promise<void> {
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -1079,7 +1106,13 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: { name: "cline-cli" },
|
||||
client: {
|
||||
name: "cline-cli",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(400_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -130,7 +130,7 @@ describe("compactInteractiveMessages", () => {
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(360_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
|
||||
@@ -61,11 +61,15 @@ export async function compactInteractiveMessages(input: {
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
modelInfo?.maxInputTokens ??
|
||||
modelInfo?.contextWindow ??
|
||||
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
|
||||
const compactionModelInfo = modelInfo
|
||||
? {
|
||||
...modelInfo,
|
||||
id: modelInfo.id ?? input.config.modelId,
|
||||
}
|
||||
: {
|
||||
id: input.config.modelId,
|
||||
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
|
||||
};
|
||||
const compact = createContextCompactionPrepareTurn(
|
||||
{
|
||||
providerConfig: resolveCompactionProviderConfig(
|
||||
@@ -106,11 +110,7 @@ export async function compactInteractiveMessages(input: {
|
||||
model: {
|
||||
id: input.config.modelId,
|
||||
provider: input.config.providerId,
|
||||
info: {
|
||||
...(modelInfo ?? {}),
|
||||
id: modelInfo?.id ?? input.config.modelId,
|
||||
maxInputTokens: maxInputTokens,
|
||||
},
|
||||
info: compactionModelInfo,
|
||||
},
|
||||
});
|
||||
if (!result?.messages) {
|
||||
|
||||
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
// The tracker moved to @cline/shared so the VSCode extension can share the
|
||||
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
|
||||
// import surface stable.
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
|
||||
@@ -157,6 +157,7 @@ function makeManager() {
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
updateSessionConnection: vi.fn(async () => {}),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -814,6 +815,83 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
|
||||
const manager = makeManager();
|
||||
const config = {
|
||||
...createConfig(),
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
config.providerId = "openai-compatible";
|
||||
config.modelId = "custom-model";
|
||||
config.apiKey = "new-key";
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
}),
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the active session connection in place without restarting", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.updateCurrentSessionConnection({
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
|
||||
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
});
|
||||
|
||||
it("does not reuse the session id when restarting empty", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
const secondStart = manager.start.mock.calls[1]?.[0] as {
|
||||
config?: { sessionId?: string };
|
||||
};
|
||||
expect(secondStart?.config?.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
|
||||
@@ -49,6 +49,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
|
||||
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
|
||||
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
|
||||
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
export type SessionConnectionUpdate = Parameters<
|
||||
CliCore["updateSessionConnection"]
|
||||
>[1];
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
@@ -210,12 +213,18 @@ export function createInteractiveSessionRuntime(input: {
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
// For continuing the same conversation, e.g. after a config change.
|
||||
sessionId?: string,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: buildSessionConfig(),
|
||||
config: {
|
||||
...buildSessionConfig(),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
@@ -411,43 +420,51 @@ export function createInteractiveSessionRuntime(input: {
|
||||
});
|
||||
};
|
||||
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
reuseSessionId,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const [{ messages, status }, compactionState] = await Promise.all([
|
||||
@@ -473,9 +490,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
{ preserveSessionId: true },
|
||||
);
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
// No live session to update; the next startup builds its config from
|
||||
// the already-mutated CLI config, so nothing else is needed.
|
||||
return;
|
||||
}
|
||||
await manager.updateSessionConnection(sessionId, update);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
await restartWithMessages([]);
|
||||
};
|
||||
@@ -840,6 +872,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
compactCurrentSession,
|
||||
|
||||
@@ -9,23 +9,6 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
|
||||
- Read files, search the codebase, and gather context to understand the problem
|
||||
- Ask clarifying questions when requirements are ambiguous
|
||||
- Present your plan as a structured outline with clear steps
|
||||
- Explain tradeoffs between different approaches when they exist
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
explicitSystemPrompt?: string;
|
||||
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
|
||||
mode?: AgentMode;
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
}
|
||||
// Mode-tag and plan-mode instructions are appended by the shared prompt
|
||||
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
|
||||
// @cline/shared), so only the caller-specific rules are merged here.
|
||||
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
workspaceRoot: input.cwd,
|
||||
|
||||
@@ -43,6 +43,15 @@ const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
"ClinePass limit reached",
|
||||
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
].join("\n");
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
extractClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
const prefix = "you have reached your";
|
||||
const suffix = "please try again later.";
|
||||
const start = normalized.indexOf(prefix);
|
||||
if (start === -1) return undefined;
|
||||
const suffixStart = normalized.indexOf(suffix, start);
|
||||
if (suffixStart === -1) return undefined;
|
||||
const end = suffixStart + suffix.length;
|
||||
if (!normalized.slice(start, end).includes("clinepass limit")) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start, end);
|
||||
},
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -769,6 +802,126 @@ describe("runAgent", () => {
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_LIMIT_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
resolveReasoningForModelChange,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModelChange", () => {
|
||||
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
} as Config;
|
||||
const getProviderSettings = vi.fn(() => ({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible" as const,
|
||||
protocol: "openai-chat" as const,
|
||||
model: "old-model",
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
const updateCurrentSessionConnection = vi.fn(async () => {});
|
||||
|
||||
await applyInteractiveModelChange({
|
||||
config,
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
},
|
||||
sessionRuntime: {
|
||||
ensureReady,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible",
|
||||
protocol: "openai-chat",
|
||||
model: "custom-model",
|
||||
});
|
||||
expect(ensureReady).toHaveBeenCalledOnce();
|
||||
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
|
||||
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
});
|
||||
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function applyInteractiveModelChange(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<
|
||||
ProviderSettingsManager,
|
||||
"getProviderSettings" | "saveProviderSettings"
|
||||
>;
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
| "ensureReady"
|
||||
| "restartWithCurrentMessages"
|
||||
| "updateCurrentSessionConnection"
|
||||
>;
|
||||
}): Promise<void> {
|
||||
const { config, providerSettingsManager, sessionRuntime } = input;
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
|
||||
// Provider changes affect more than the model connection: startup resolves
|
||||
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
|
||||
// the runtime with the existing transcript so all of that state changes
|
||||
// together. restartWithCurrentMessages preserves the session ID.
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
// A same-ID restart reuses the existing manifest. Sync its connection label
|
||||
// after the fully configured runtime is live so session history reflects the
|
||||
// provider/model that will handle subsequent turns.
|
||||
await sessionRuntime.updateCurrentSessionConnection({
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -687,25 +732,12 @@ export async function runInteractive(
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
onModelChange: () =>
|
||||
applyInteractiveModelChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
providerSettingsManager,
|
||||
sessionRuntime,
|
||||
}),
|
||||
onSessionRestart: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.restartEmpty();
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -419,6 +421,54 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Switch to Cline usage-based billing and retry with the Cline provider."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Interactive CLI: </text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="type /model, press tab to change provider, choose Cline, then retry."
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Headless CLI: </text>
|
||||
<text fg={props.defaultFg} selectable content="rerun with " />
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -534,6 +584,15 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
import {
|
||||
getProviderAuthStorageId,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
/**
|
||||
* Persist a manually entered API key for an OAuth-capable provider — the
|
||||
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
|
||||
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
|
||||
* stale token would otherwise keep winning over the manual key.
|
||||
*
|
||||
* The key is written both to the provider's auth storage entry (cline-pass
|
||||
* stores credentials under "cline") and to the provider's own entry: settings
|
||||
* resolution lets a direct entry shadow the storage entry, and provider
|
||||
* switching copies merged settings (including auth) into direct entries, so
|
||||
* both must be updated for the manual key to reliably take effect.
|
||||
*/
|
||||
export function saveManualProviderApiKey(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): void {
|
||||
// Empty strings delete these keys from the stored auth object.
|
||||
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId: storageProviderId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
if (
|
||||
providerId !== storageProviderId &&
|
||||
manager.read().providers[providerId]
|
||||
) {
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isProviderConfigured,
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
@@ -16,3 +27,99 @@ describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveManualProviderApiKey", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createManager(): ProviderSettingsManager {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
|
||||
tempDirs.push(dir);
|
||||
return new ProviderSettingsManager({
|
||||
filePath: join(dir, "providers.json"),
|
||||
});
|
||||
}
|
||||
|
||||
it("clears stored OAuth tokens so the manual key takes effect", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
accountId: "acct_123",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline", "manual-api-key");
|
||||
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
expect(settings?.apiKey).toBe("manual-api-key");
|
||||
expect(settings?.auth?.accessToken).toBeUndefined();
|
||||
expect(settings?.auth?.refreshToken).toBeUndefined();
|
||||
expect(settings?.auth?.accountId).toBe("acct_123");
|
||||
expect(getPersistedProviderApiKey("cline", settings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline", settings)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves cline-pass keys to the shared cline auth storage entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
// cline-pass inherits auth storage from the "cline" entry, so the key
|
||||
// must land there and the stale tokens must be gone for both providers.
|
||||
const clineSettings = manager.getProviderSettings("cline");
|
||||
expect(clineSettings?.apiKey).toBe("manual-api-key");
|
||||
expect(clineSettings?.auth?.accessToken).toBeUndefined();
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears stale credentials copied into a direct cline-pass entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
// Provider switching copies the merged settings (including auth) into
|
||||
// a direct cline-pass entry, which shadows the shared "cline" entry.
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline-pass",
|
||||
apiKey: "stale-copied-key",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -724,13 +727,27 @@ export function CodexCliStatusContent(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `true` on successful login, `"use_api_key"` when the user opts
|
||||
* into manual API key entry (only offered with `allowApiKeyFallback`).
|
||||
*/
|
||||
export type OAuthLoginResult = boolean | "use_api_key";
|
||||
|
||||
export function OAuthLoginContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
props: ChoiceContext<OAuthLoginResult> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
allowApiKeyFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerId, providerName } = props;
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -863,9 +880,19 @@ export function OAuthLoginContent(
|
||||
if (key.name === "escape") {
|
||||
cancelAuthAttempt();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "k" && allowApiKeyFallback) {
|
||||
cancelAuthAttempt();
|
||||
resolve("use_api_key");
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
@@ -892,8 +919,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -915,8 +942,83 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual API key entry for OAuth-capable providers — the escape hatch for
|
||||
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
|
||||
* the manual key takes effect (see saveManualProviderApiKey).
|
||||
*/
|
||||
export function OAuthApiKeyInputContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const apiKey = value.trim();
|
||||
if (!apiKey) return;
|
||||
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
submit();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
Use an API key from your Cline dashboard instead of OAuth login. This
|
||||
replaces any saved login tokens.
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">API key</text>
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<input
|
||||
value={value}
|
||||
onInput={setValue}
|
||||
placeholder="Paste your API key"
|
||||
flexGrow={1}
|
||||
focused
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Enter to save, Esc to go back</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: ClineModelPickerTier;
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
|
||||
ClineModelPickerTier,
|
||||
string
|
||||
> = {
|
||||
recommended: "Recommended",
|
||||
subscribed: "Subscribed",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
// Featured entries for the sectioned picker, keyed by provider: cline gets
|
||||
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
|
||||
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
|
||||
export function buildFeaturedModelEntries(
|
||||
providerId: string,
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
return providerId === "cline-pass"
|
||||
? buildClinePassModelEntries(data)
|
||||
: buildClineModelEntries(data);
|
||||
}
|
||||
|
||||
function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Shown under the Free section header when picking a model for ClinePass
|
||||
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
|
||||
"Try with limited usage, separate from ClinePass quota.";
|
||||
|
||||
// ClinePass shows the subscription's models plus the Cline free models — both
|
||||
// providers hit the same Cline API, so free models are selectable in place
|
||||
// (they ride usage billing at $0 instead of the subscription quota).
|
||||
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
|
||||
// the ClinePass catalog contains exactly these two buckets, so the sections
|
||||
// already list every selectable model. An empty clinePass bucket means the
|
||||
// fetch fell back to the bundled list (which has no pass models) — without an
|
||||
// escape into the full catalog a subscriber could only pick free models, so
|
||||
// browse-all comes back in that degraded mode.
|
||||
function buildClinePassModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.clinePass) {
|
||||
entries.push({ kind: "model", model: m, tier: "subscribed" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
if (data.clinePass.length === 0) {
|
||||
entries.push({ kind: "browse" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The quota explainer only makes sense in the ClinePass picker, which is the
|
||||
// only picker that has a "subscribed" section
|
||||
export function freeTierDescriptionFor(
|
||||
entries: ClineModelPickerEntry[],
|
||||
): string | undefined {
|
||||
const isClinePassPicker = entries.some(
|
||||
(entry) => entry.kind === "model" && entry.tier === "subscribed",
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
|
||||
describe("cline model picker entries", () => {
|
||||
it("builds Recommended/Free sections for the cline provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("anthropic/claude-sonnet-5"),
|
||||
tier: "recommended",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds Subscribed/Free sections for the cline-pass provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
|
||||
{
|
||||
kind: "model",
|
||||
model: model("cline-pass/kimi-k2.6"),
|
||||
tier: "subscribed",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds the browse-all escape when the clinePass bucket is empty", () => {
|
||||
// The fetch fell back to the bundled list (no pass models); the sections
|
||||
// alone would leave a subscriber able to pick only free models.
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
|
||||
const data = {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
|
||||
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
@@ -9,20 +8,23 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
export {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerBrowse,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
@@ -39,12 +41,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
@@ -68,20 +71,6 @@ export function useClineRecommendedModels() {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function ClineModelPicker(props: {
|
||||
entries: ClineModelPickerEntry[];
|
||||
selected: number;
|
||||
@@ -103,6 +92,7 @@ export function ClineModelPicker(props: {
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const rows: ReactNode[] = [];
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -112,14 +102,20 @@ export function ClineModelPicker(props: {
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
|
||||
rows.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
{entry.tier === "free" && freeTierDescription && (
|
||||
<text fg="gray">
|
||||
<em>{freeTierDescription}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
@@ -29,12 +34,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
@@ -62,11 +68,13 @@ export function ClineModelSelectorContent(
|
||||
key: string;
|
||||
kind: "header" | "model" | "browse";
|
||||
label: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}[] = [];
|
||||
let lastTier: string | null = null;
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
@@ -76,7 +84,9 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
|
||||
description:
|
||||
entry.tier === "free" ? freeTierDescription : undefined,
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
@@ -156,8 +166,18 @@ export function ClineModelSelectorContent(
|
||||
if (row.kind === "header") {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
marginTop={isFirst ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{row.label}</text>
|
||||
{row.description && (
|
||||
<text fg="gray">
|
||||
<em>{row.description}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type AccountDialogAction,
|
||||
AccountDialogContent,
|
||||
} from "../components/dialogs/account-dialog";
|
||||
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
|
||||
import {
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export function useAccountDialog(opts: {
|
||||
@@ -60,14 +63,14 @@ export function useAccountDialog(opts: {
|
||||
return;
|
||||
}
|
||||
if (action === "login") {
|
||||
const saved = await dialog.choice<boolean>({
|
||||
const saved = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
if (saved === true) {
|
||||
await onAccountChange?.();
|
||||
await openAccountDialog();
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
refreshProviderModelsFromSource,
|
||||
resolveProviderConfig,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
UseExistingOrReconfigureContent,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
@@ -79,6 +82,49 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
|
||||
* using the provider's stored API key and headers, mirroring the extension's
|
||||
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
|
||||
* to manual model-id entry.
|
||||
*/
|
||||
async function fetchOpenAiCompatibleModelIds(
|
||||
providerId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const config = manager.getProviderConfig(providerId, { includeKnownModels: false });
|
||||
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
|
||||
if (!baseUrl || !URL.canParse(baseUrl)) return [];
|
||||
|
||||
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
|
||||
const apiKey = config?.apiKey?.trim();
|
||||
if (
|
||||
apiKey &&
|
||||
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
|
||||
) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const ids = list
|
||||
.map((model) => {
|
||||
const id = (model as { id?: unknown } | null)?.id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return [...new Set(ids)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function providerToExistingProviderOptions(input: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -131,6 +177,23 @@ async function runProviderChange(
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
|
||||
// Manual API key entry is the escape hatch for when OAuth login isn't
|
||||
// working; only the Cline providers accept a dashboard API key.
|
||||
const supportsManualApiKey = isClineProvider(newProviderId);
|
||||
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
|
||||
await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<OAuthApiKeyInputContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
providerSettingsManager={manager}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
let needsAuth = true;
|
||||
if (isProviderConfigured(newProviderId, existingSettings)) {
|
||||
let option: ExistingProviderOption | undefined;
|
||||
@@ -165,17 +228,22 @@ async function runProviderChange(
|
||||
if (needsAuth) {
|
||||
let saved: boolean | undefined;
|
||||
if (isOAuthProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
const loginResult = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
allowApiKeyFallback={supportsManualApiKey}
|
||||
/>
|
||||
),
|
||||
});
|
||||
saved =
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
@@ -275,12 +343,28 @@ export function useModelSelector(opts: {
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
let providerDisplayName = config.providerId;
|
||||
let endpointModelOptions: ModelOption[] = [];
|
||||
|
||||
const refreshProviderContext = async () => {
|
||||
modelOptions = buildModelOptions(
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
providerDisplayName = await getProviderDisplayName(config.providerId);
|
||||
// Free-text providers (openai-compatible) can still suggest model
|
||||
// ids when their endpoint answers /models; otherwise they keep the
|
||||
// manual input.
|
||||
endpointModelOptions = usesModelIdInput(config.providerId)
|
||||
? buildModelOptions(
|
||||
Object.fromEntries(
|
||||
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
|
||||
(id) => [id, { id, name: id }],
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (endpointModelOptions.length > 0) {
|
||||
modelOptions = endpointModelOptions;
|
||||
}
|
||||
};
|
||||
|
||||
if (!options?.startWithProviderChange) {
|
||||
@@ -316,7 +400,10 @@ export function useModelSelector(opts: {
|
||||
let pickingModel = true;
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
if (
|
||||
usesModelIdInput(config.providerId) &&
|
||||
endpointModelOptions.length === 0
|
||||
) {
|
||||
const modelId = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -341,7 +428,13 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.providerId === "cline") {
|
||||
if (
|
||||
config.providerId === "cline" ||
|
||||
config.providerId === "cline-pass"
|
||||
) {
|
||||
// ClinePass gets the same sectioned picker with Subscribed/Free
|
||||
// sections — free models are selectable while staying on ClinePass
|
||||
const featuredProviderId = config.providerId;
|
||||
const clineResult = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -351,7 +444,10 @@ export function useModelSelector(opts: {
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
buildFeaturedModelEntries(
|
||||
featuredProviderId,
|
||||
await fetchClineRecommendedModels(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type TerminalTitleRenderer,
|
||||
useTerminalTitle,
|
||||
} from "./use-terminal-title";
|
||||
|
||||
const reactMock = vi.hoisted(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
return {
|
||||
cleanups,
|
||||
// Run effect bodies now, but retain their cleanups so each test can move
|
||||
// the renderer across the native destruction boundary before unmount.
|
||||
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) {
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: reactMock.useEffect,
|
||||
}));
|
||||
|
||||
function createTitleRenderer() {
|
||||
let destroyed = false;
|
||||
const setTerminalTitle = vi.fn(() => {
|
||||
if (destroyed) {
|
||||
throw new Error("setTerminalTitle called after renderer destruction");
|
||||
}
|
||||
});
|
||||
const renderer: TerminalTitleRenderer = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
setTerminalTitle,
|
||||
};
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
renderer,
|
||||
setTerminalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactMock.cleanups.length = 0;
|
||||
reactMock.useEffect.mockClear();
|
||||
});
|
||||
|
||||
describe("useTerminalTitle", () => {
|
||||
it("sets and resets the title while the renderer is active", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
|
||||
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
|
||||
});
|
||||
|
||||
it("does not set the title when its effect runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
titleRenderer.destroy();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset the title when cleanup runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
|
||||
titleRenderer.destroy();
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TerminalTitleRenderer {
|
||||
readonly isDestroyed: boolean;
|
||||
setTerminalTitle(title: string): void;
|
||||
}
|
||||
|
||||
export function useTerminalTitle(
|
||||
renderer: TerminalTitleRenderer,
|
||||
terminalTitle: string,
|
||||
): void {
|
||||
// setTerminalTitle writes into memory owned by the native renderer, so it
|
||||
// must never run after destroy. React can flush passive effects after the
|
||||
// renderer's memory has been freed.
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
|
||||
defaultBackground: null,
|
||||
defaultForeground: null,
|
||||
})),
|
||||
isDestroyed: false,
|
||||
on: vi.fn(),
|
||||
setTerminalTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
const rootMock = vi.hoisted(() => ({
|
||||
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
destroyHandlers.length = 0;
|
||||
rendererMock.isDestroyed = false;
|
||||
rendererMock.destroy.mockReset();
|
||||
rendererMock.setTerminalTitle.mockReset();
|
||||
rendererMock.on.mockReset();
|
||||
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === "destroy") {
|
||||
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the terminal title before destroying the renderer", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
const titleCallOrder =
|
||||
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
|
||||
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
|
||||
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
|
||||
});
|
||||
|
||||
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
// Simulate OpenTUI's own signal handler destroying the renderer in the
|
||||
// same dispatch (e.g. an idle SIGTERM fires both our handler and
|
||||
// OpenTUI's exitHandler before microtasks drain).
|
||||
rendererMock.isDestroyed = true;
|
||||
for (const handler of destroyHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function renderOpenTui(
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
// Reset the title while the native renderer is still alive; the
|
||||
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
|
||||
// Re-check here: OpenTUI's own signal handlers can destroy the
|
||||
// renderer between destroy() queuing this microtask and it running
|
||||
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
|
||||
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import type { AppView, TuiProps } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
@@ -472,15 +473,7 @@ function App(props: TuiProps) {
|
||||
};
|
||||
}, [renderer, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
renderer.setTerminalTitle("");
|
||||
};
|
||||
}, [renderer]);
|
||||
useTerminalTitle(renderer, terminalTitle);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
loadIndividualSubscriptionPlansFromProviderSettings,
|
||||
} from "../../cline-account";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildFeaturedModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
@@ -206,11 +206,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
|
||||
// Cline featured model picker
|
||||
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
|
||||
const recommended = useClineRecommendedModels();
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
() =>
|
||||
recommended.data
|
||||
? buildFeaturedModelEntries(activeProviderId, recommended.data)
|
||||
: [],
|
||||
[recommended.data, activeProviderId],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
@@ -221,20 +224,37 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLocalProviderModels("cline")
|
||||
.then(({ models }) => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of models) {
|
||||
// The featured picker serves both cline and cline-pass, so pool reasoning
|
||||
// support and display names from both catalogs
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
getLocalProviderModels(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const ids = new Set<string>();
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
for (const m of result.value.models) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
})
|
||||
.catch(() => {});
|
||||
resolveProviderConfig("cline")
|
||||
.then((resolved) => {
|
||||
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
});
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
resolveProviderConfig(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value?.knownModels) {
|
||||
Object.assign(merged, result.value.knownModels);
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged).length > 0) {
|
||||
setClineKnownModels(merged);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
|
||||
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,5 +207,6 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
// ClinePass uses the featured picker too, with Subscribed/Free sections
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const detail =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
|
||||
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClinePassLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Switch to Cline usage-based billing",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
@@ -24,6 +27,18 @@ export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getCliClinePassLimitMessage(message: string): string {
|
||||
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
|
||||
const lines = [
|
||||
"ClinePass limit reached",
|
||||
detail,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
];
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
);
|
||||
}
|
||||
|
||||
export function getClinePassLimitDetailMessage(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
return extractClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClinePassLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClinePassLimitError" ||
|
||||
isClinePassLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
const config = createConfig({ enabled: true, maxInputTokens: 123 });
|
||||
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
|
||||
|
||||
applyCliCompactionMode(config, "basic");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("basic");
|
||||
|
||||
applyCliCompactionMode(config, "off");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: false,
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("off");
|
||||
});
|
||||
|
||||
@@ -1,19 +1,64 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
describe("resolveStatusNoticeLabel", () => {
|
||||
it("maps compaction status reasons to stable labels", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "auto-compacting",
|
||||
reason: "auto_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("auto-compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "manual",
|
||||
reason: "manual_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction-budget-adjusted",
|
||||
reason: "compaction_budget_emergency",
|
||||
} as AgentEvent),
|
||||
).toBe("context budget adjusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errorOutput = "";
|
||||
setCurrentOutputMode("text");
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
errorOutput += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
errorOutput += `${args.map(String).join(" ")}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a ⎿ before text that follows a tool block", () => {
|
||||
@@ -160,6 +205,23 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toContain("── aborted (2 iterations) ──");
|
||||
});
|
||||
|
||||
it("formats ClinePass limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("ClinePass limit reached");
|
||||
expect(errorOutput).toContain("Switch to Cline usage-based billing");
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -27,8 +28,13 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
switch (event.reason) {
|
||||
case "auto_compaction":
|
||||
return "auto-compacting";
|
||||
case "manual_compaction":
|
||||
return "compacting";
|
||||
case "compaction_budget_emergency":
|
||||
return "context budget adjusted";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
@@ -176,7 +182,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(event.error.message);
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -53,6 +53,37 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("zeros cost of free models selected on the cline-pass provider", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// subscription (cline-pass/...) models are not in the free bucket
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -73,7 +73,9 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
// Free models are also selectable on ClinePass — they ride usage billing at $0
|
||||
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
|
||||
return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
|
||||
@@ -42,11 +42,12 @@ export function getPersistedProviderApiKey(
|
||||
* or endpoint config for the provider. Used by the picker to decide whether
|
||||
* to offer "Use existing configuration?" before opening the configure dialog.
|
||||
*
|
||||
* Treats OAuth providers as configured when an access token is present; for
|
||||
* everything else, any persisted API key, base URL, or model id counts. We
|
||||
* don't enforce required fields here — the runtime no longer pre-flights
|
||||
* credentials, so a missing key only matters when the API call actually
|
||||
* runs and the provider's own auth error is surfaced.
|
||||
* Treats OAuth providers as configured when an access token or a manually
|
||||
* saved API key is present (the /settings escape hatch for when OAuth isn't
|
||||
* working); for everything else, any persisted API key, base URL, or model id
|
||||
* counts. We don't enforce required fields here — the runtime no longer
|
||||
* pre-flights credentials, so a missing key only matters when the API call
|
||||
* actually runs and the provider's own auth error is surfaced.
|
||||
*/
|
||||
export function isProviderConfigured(
|
||||
providerId: string,
|
||||
@@ -54,7 +55,8 @@ export function isProviderConfigured(
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
|
||||
return Boolean(getPersistedProviderApiKey(providerId, settings));
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
if (settings.baseUrl?.trim()) return true;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
@@ -103,7 +104,9 @@ export async function handleDesktopCommand(
|
||||
): Promise<unknown> {
|
||||
if (command === "list_provider_catalog") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
@@ -165,6 +168,11 @@ export async function handleDesktopCommand(
|
||||
providerId,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(providerSettingsManager, providerId, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
@@ -99,7 +100,9 @@ export async function sendProviderCatalog(
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
ctx.send(peer, {
|
||||
type: "provider_catalog",
|
||||
providers: payload.providers,
|
||||
@@ -138,6 +141,11 @@ export async function runProviderOAuthLogin(
|
||||
normalized,
|
||||
openExternalUrl,
|
||||
);
|
||||
if (saved.provider !== normalized) {
|
||||
markLocalProviderEnabled(providerSettingsManager, normalized, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
providerId: normalized,
|
||||
|
||||
@@ -20,10 +20,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
// react-markdown/streamdown pass the hast `Element` here, whose
|
||||
// `properties` is a broad `Record`. Keep this assignable from that type
|
||||
// (rather than a narrow `{ metastring?: string }`) so the component stays
|
||||
// compatible with `Components` regardless of how strict the resolved
|
||||
// hast/streamdown types are; the metastring value is validated at read time.
|
||||
node?: {
|
||||
properties?: {
|
||||
metastring?: string;
|
||||
};
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -67,7 +70,8 @@ const MarkdownCode = ({
|
||||
);
|
||||
}
|
||||
|
||||
const meta = node?.properties?.metastring;
|
||||
const metaValue = node?.properties?.metastring;
|
||||
const meta = typeof metaValue === "string" ? metaValue : undefined;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
@@ -258,8 +258,8 @@ export function SettingsView({
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -386,7 +386,7 @@ export function SettingsView({
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
usesOAuth(selectedProvider)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Provider {
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
capabilities?: string[];
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
|
||||
@@ -13,8 +13,51 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Shareable Desktop Packages
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
- macOS: `bun run package:desktop:mac`
|
||||
- Windows: `bun run package:desktop:windows`
|
||||
- Linux: `bun run package:desktop:linux`
|
||||
|
||||
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
|
||||
|
||||
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
|
||||
|
||||
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
|
||||
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
|
||||
|
||||
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
|
||||
|
||||
### macOS signing & notarization, step by step
|
||||
|
||||
One-time keychain setup:
|
||||
|
||||
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
|
||||
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
|
||||
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
|
||||
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
|
||||
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
|
||||
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
|
||||
|
||||
Per-build:
|
||||
|
||||
```bash
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
|
||||
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
|
||||
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
|
||||
export APPLE_API_ISSUER="<issuer UUID>"
|
||||
bun run package:desktop:mac
|
||||
```
|
||||
|
||||
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
|
||||
|
||||
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
Startup flow:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
@@ -10,6 +10,11 @@
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
@@ -19,7 +24,8 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type DesktopPlatform = "mac" | "windows" | "linux";
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
);
|
||||
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
|
||||
|
||||
process.chdir(APP_ROOT);
|
||||
|
||||
const validateArgs = (): void => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`missing value for ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
|
||||
throw new Error(
|
||||
suggestion
|
||||
? `unknown option ${arg}. Did you mean ${suggestion}?`
|
||||
: `unknown option ${arg}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argument ${arg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getArgValue = (name: string): string | undefined => {
|
||||
const prefix = `${name}=`;
|
||||
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) {
|
||||
return inline.slice(prefix.length);
|
||||
}
|
||||
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index >= 0) {
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasArg = (name: string): boolean => process.argv.includes(name);
|
||||
|
||||
const hostPlatform = (): DesktopPlatform => {
|
||||
if (process.platform === "darwin") {
|
||||
return "mac";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "windows";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return "linux";
|
||||
}
|
||||
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
|
||||
};
|
||||
|
||||
const resolveRequestedPlatform = (): DesktopPlatform => {
|
||||
const platform =
|
||||
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
|
||||
if (platform === "current") {
|
||||
return hostPlatform();
|
||||
}
|
||||
if (platform === "mac" || platform === "windows" || platform === "linux") {
|
||||
return platform;
|
||||
}
|
||||
throw new Error(
|
||||
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeName = (value: string): string =>
|
||||
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
|
||||
|
||||
const packageVersion = async (): Promise<string> => {
|
||||
const packageJson = await Bun.file(
|
||||
path.join(APP_ROOT, "package.json"),
|
||||
).json();
|
||||
return String(packageJson.version ?? "0.0.0");
|
||||
};
|
||||
|
||||
const macDistributionCredentialsConfigured = (): boolean => {
|
||||
const hasCertificate = Boolean(
|
||||
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
|
||||
);
|
||||
const hasAppleIdNotarization = Boolean(
|
||||
process.env.APPLE_ID &&
|
||||
process.env.APPLE_PASSWORD &&
|
||||
process.env.APPLE_TEAM_ID,
|
||||
);
|
||||
const hasApiKeyNotarization = Boolean(
|
||||
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
|
||||
process.env.APPLE_API_KEY_ID &&
|
||||
process.env.APPLE_API_ISSUER,
|
||||
);
|
||||
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
|
||||
};
|
||||
|
||||
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
|
||||
const host = hostPlatform();
|
||||
if (platform !== host) {
|
||||
throw new Error(
|
||||
[
|
||||
`cannot build ${platform} desktop bundles from ${host}.`,
|
||||
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
|
||||
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
|
||||
if (hostPlatform() !== "mac") {
|
||||
return;
|
||||
}
|
||||
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
|
||||
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
|
||||
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
|
||||
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
|
||||
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
|
||||
const walkFiles = (root: string): string[] => {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = path.join(root, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
paths.push(...walkFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
paths.push(fullPath);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const copyArtifact = (source: string, outputName: string): string => {
|
||||
const destination = path.join(PACKAGE_ROOT, outputName);
|
||||
rmSync(destination, { force: true, recursive: true });
|
||||
cpSync(source, destination, { recursive: true });
|
||||
return destination;
|
||||
};
|
||||
|
||||
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --force --deep --sign - ${appPath}`;
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const verifySignedMacApp = async (appPath: string): Promise<void> => {
|
||||
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
|
||||
await $`spctl --assess --type execute --verbose ${appPath}`;
|
||||
await $`xattr -cr ${appPath}`;
|
||||
};
|
||||
|
||||
const collectMacArtifacts = async (
|
||||
version: string,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
|
||||
if (!existsSync(appPath)) {
|
||||
throw new Error(`macOS app bundle was not created at ${appPath}`);
|
||||
}
|
||||
|
||||
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
|
||||
console.warn(
|
||||
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
|
||||
);
|
||||
await signUnsignedMacApp(appPath);
|
||||
} else {
|
||||
await verifySignedMacApp(appPath);
|
||||
}
|
||||
|
||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
const suffix =
|
||||
allowUnsignedMac && !macDistributionCredentialsConfigured()
|
||||
? "-local-unsigned"
|
||||
: "";
|
||||
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
|
||||
const zipPath = path.join(PACKAGE_ROOT, zipName);
|
||||
rmSync(zipPath, { force: true });
|
||||
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
|
||||
|
||||
const artifacts = [zipPath];
|
||||
if (!suffix) {
|
||||
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
|
||||
(file) => file.endsWith(".dmg"),
|
||||
)) {
|
||||
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
};
|
||||
|
||||
const collectWindowsArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectLinuxArtifacts = (): string[] =>
|
||||
walkFiles(BUNDLE_ROOT)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.endsWith(".AppImage") ||
|
||||
file.endsWith(".deb") ||
|
||||
file.endsWith(".rpm"),
|
||||
)
|
||||
.map((file) => copyArtifact(file, path.basename(file)));
|
||||
|
||||
const collectArtifacts = async (
|
||||
platform: DesktopPlatform,
|
||||
allowUnsignedMac: boolean,
|
||||
): Promise<string[]> => {
|
||||
const version = await packageVersion();
|
||||
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
|
||||
mkdirSync(PACKAGE_ROOT, { recursive: true });
|
||||
|
||||
if (platform === "mac") {
|
||||
return collectMacArtifacts(version, allowUnsignedMac);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsArtifacts();
|
||||
}
|
||||
return collectLinuxArtifacts();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
validateArgs();
|
||||
|
||||
const platform = resolveRequestedPlatform();
|
||||
const allowUnsignedMac =
|
||||
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
|
||||
const skipBuild = hasArg("--skip-build");
|
||||
|
||||
assertCanBuildPlatform(platform);
|
||||
if (platform === "mac") {
|
||||
assertMacDistributionReady(allowUnsignedMac);
|
||||
}
|
||||
|
||||
if (!skipBuild) {
|
||||
await $`bun run build:binary`;
|
||||
}
|
||||
|
||||
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(
|
||||
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Packaged ${platform} desktop artifacts:`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionConnectionUpdate } from "./chat-session";
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinking")).toBe(false);
|
||||
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears reasoning settings when thinking is explicitly disabled", () => {
|
||||
expect(
|
||||
buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: false,
|
||||
reasoningEffort: null,
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates explicit reasoning settings without clearing omitted settings", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
@@ -20,6 +21,10 @@ import type {
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -103,7 +108,40 @@ function isoTimestampToMs(
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readReasoningEffort(
|
||||
value: unknown,
|
||||
): "low" | "medium" | "high" | "xhigh" | undefined {
|
||||
if (
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens =
|
||||
thinking === false
|
||||
? undefined
|
||||
: readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return {
|
||||
sessionId: config.sessionId ?? config.session_id,
|
||||
providerId: config.provider ?? config.providerId ?? "",
|
||||
@@ -125,6 +163,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
config.enableAgentTeams ??
|
||||
config.enable_teams ??
|
||||
false,
|
||||
...(thinking !== undefined ? { thinking } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
teamName: config.teamName ?? config.team_name,
|
||||
missionLogIntervalSteps:
|
||||
config.missionStepInterval ?? config.missionLogIntervalSteps,
|
||||
@@ -136,6 +177,48 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
|
||||
// into typed fields; the thinking/reasoning transition rules live in the
|
||||
// shared @cline/core builder.
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
const rawApiKey =
|
||||
typeof config.apiKey === "string"
|
||||
? config.apiKey.trim()
|
||||
: typeof config.api_key === "string"
|
||||
? config.api_key.trim()
|
||||
: undefined;
|
||||
const baseUrl =
|
||||
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return buildConnectionUpdate({
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(rawApiKey ? { apiKey: rawApiKey } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(config.headers && typeof config.headers === "object"
|
||||
? { headers: config.headers as Record<string, string> }
|
||||
: {}),
|
||||
...(config.providerConfig && typeof config.providerConfig === "object"
|
||||
? {
|
||||
providerConfig:
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"],
|
||||
}
|
||||
: {}),
|
||||
...(typeof config.thinking === "boolean"
|
||||
? { thinking: config.thinking }
|
||||
: {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -363,6 +446,13 @@ async function handleSend(
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective delivery mode.
|
||||
// When the session is busy and no explicit delivery was requested, queue it
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -35,13 +36,26 @@ import {
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
findArtifactUnderDir,
|
||||
readSessionManifest,
|
||||
@@ -945,7 +959,7 @@ export async function handleCommand(
|
||||
if (command === "list_provider_catalog") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
await ensureCustomProvidersLoaded(manager);
|
||||
return await listLocalProviders(manager);
|
||||
return await listLocalProviders(manager, { isClinePassEnabled: true });
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -1025,12 +1039,45 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Global settings ────────────────────────────────────────────────
|
||||
if (command === "get_global_settings") {
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_telemetry_opt_out") {
|
||||
if (typeof args?.telemetry_opt_out !== "boolean") {
|
||||
throw new Error("telemetry_opt_out must be a boolean");
|
||||
}
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return await startConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
if (command === "stop_connector_channel") {
|
||||
return await stopConnectorChannel(ctx.workspaceRoot, args);
|
||||
}
|
||||
|
||||
// ── MCP server management ─────────────────────────────────────────
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
@@ -1156,6 +1203,26 @@ export async function handleCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(ctx.workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type WebviewConnectorChannelsResponse = {
|
||||
available: WebviewConnectorChannel[];
|
||||
active: ReturnType<typeof listActiveConnectors>;
|
||||
};
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath =
|
||||
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
type: platform.type,
|
||||
hint: platform.hint,
|
||||
fields: platform.fields.map((field) => ({
|
||||
flag: field.flag,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
prompt: platform.security.prompt,
|
||||
fields: platform.security.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
requiredMessage: field.requiredMessage,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { available, active: listActiveConnectors() };
|
||||
}
|
||||
|
||||
async function runCliConnectCommand(
|
||||
workspaceRoot: string,
|
||||
args: string[],
|
||||
): Promise<{
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve(exitCode ?? 0));
|
||||
});
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function waitForConnectorState(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`connector did not reach expected state within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(platform.id)) {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
() =>
|
||||
!listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import type { SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -14,7 +17,18 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
@@ -39,8 +53,20 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -57,6 +83,15 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -67,11 +102,20 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
@@ -148,6 +192,8 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
|
||||
@@ -5,10 +5,13 @@ import { dirname } from "node:path";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type CoreSessionEvent,
|
||||
NodeHubClient,
|
||||
resolveHubOwnerContext,
|
||||
type RuntimeCapabilities,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -386,6 +389,7 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
@@ -430,6 +434,12 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -682,10 +692,19 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
backendMode: "hub",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -703,6 +722,7 @@ export async function initializeSessionManager(
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
@@ -716,5 +736,6 @@ export async function initializeSessionManager(
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "./context";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { BunRuntime, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -59,8 +59,10 @@ async function main() {
|
||||
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const wsEndpoint = `ws://127.0.0.1:${port}/transport`;
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir as osHomedir, platform } from "node:os";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
|
||||
|
||||
type MarketplaceEnvVar = {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallInput = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name?: string;
|
||||
install: {
|
||||
args?: string[];
|
||||
env?: MarketplaceEnvVar[];
|
||||
command?: string;
|
||||
notes?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type MarketplaceInstallResult = {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
status: "installed" | "uninstalled";
|
||||
message: string;
|
||||
details?: JsonRecord;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallStatusResult = {
|
||||
installedKeys: string[];
|
||||
};
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type SpawnCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
) => Promise<SpawnResult>;
|
||||
type CatalogFetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
type CatalogLoader = () => Promise<unknown>;
|
||||
|
||||
const MAX_OUTPUT_CHARS = 12_000;
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
|
||||
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
|
||||
const MARKETPLACE_CATALOG_URL =
|
||||
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
const SECRET_PATTERN =
|
||||
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
|
||||
const SECRET_KEY_VALUE_PATTERN =
|
||||
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
|
||||
const SECRET_BEARER_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
|
||||
const SECRET_AUTHORIZATION_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
|
||||
|
||||
export async function fetchMarketplaceCatalog(
|
||||
fetchImpl: CatalogFetch = fetch,
|
||||
): Promise<unknown> {
|
||||
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
|
||||
return value === "mcp" || value === "skill" || value === "plugin";
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function readInstallInput(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput {
|
||||
const entry = readInstallRecord(args);
|
||||
const install =
|
||||
entry.install && typeof entry.install === "object"
|
||||
? (entry.install as Record<string, unknown>)
|
||||
: {};
|
||||
const installArgs = toStringArray(install.args);
|
||||
if (installArgs.length === 0) {
|
||||
throw new Error("marketplace install args are required");
|
||||
}
|
||||
const env = Array.isArray(install.env)
|
||||
? install.env
|
||||
.map((item): MarketplaceEnvVar | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
if (typeof candidate.name !== "string") return null;
|
||||
const parsed: MarketplaceEnvVar = {
|
||||
name: candidate.name,
|
||||
};
|
||||
if (typeof candidate.required === "boolean") {
|
||||
parsed.required = candidate.required;
|
||||
}
|
||||
if (typeof candidate.description === "string") {
|
||||
parsed.description = candidate.description;
|
||||
}
|
||||
if (typeof candidate.url === "string") {
|
||||
parsed.url = candidate.url;
|
||||
}
|
||||
return parsed;
|
||||
})
|
||||
.filter((item): item is MarketplaceEnvVar => item !== null)
|
||||
: undefined;
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
install: {
|
||||
args: installArgs,
|
||||
command:
|
||||
typeof install.command === "string" ? install.command : undefined,
|
||||
env,
|
||||
notes: typeof install.notes === "string" ? install.notes : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRecord(
|
||||
args?: Record<string, unknown>,
|
||||
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
|
||||
const entry =
|
||||
args?.entry && typeof args.entry === "object"
|
||||
? (args.entry as Record<string, unknown>)
|
||||
: (args ?? {});
|
||||
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
|
||||
throw new Error("marketplace entry id is required");
|
||||
}
|
||||
if (!isPrimitiveType(entry.type)) {
|
||||
throw new Error("marketplace entry type must be mcp, skill, or plugin");
|
||||
}
|
||||
return entry as Record<string, unknown> & {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRequest(args?: Record<string, unknown>) {
|
||||
const entry = readInstallRecord(args);
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
};
|
||||
}
|
||||
|
||||
function readLocalUninstallInput(args?: Record<string, unknown>): {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
name?: string;
|
||||
path?: string;
|
||||
} {
|
||||
const type = typeof args?.type === "string" ? args.type.trim() : "";
|
||||
if (
|
||||
type !== "mcp" &&
|
||||
type !== "skill" &&
|
||||
type !== "workflow" &&
|
||||
type !== "plugin"
|
||||
) {
|
||||
throw new Error(
|
||||
"local uninstall type must be mcp, skill, workflow, or plugin",
|
||||
);
|
||||
}
|
||||
const id =
|
||||
typeof args?.id === "string" && args.id.trim().length > 0
|
||||
? args.id.trim()
|
||||
: typeof args?.name === "string" && args.name.trim().length > 0
|
||||
? args.name.trim()
|
||||
: typeof args?.path === "string" && args.path.trim().length > 0
|
||||
? args.path.trim()
|
||||
: "";
|
||||
if (!id) {
|
||||
throw new Error("local uninstall id, name, or path is required");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: typeof args?.name === "string" ? args.name.trim() : undefined,
|
||||
path: typeof args?.path === "string" ? args.path.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallInputList(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput[] {
|
||||
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
|
||||
return rawEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
|
||||
const catalogEntries =
|
||||
catalog && typeof catalog === "object"
|
||||
? (catalog as Record<string, unknown>).entries
|
||||
: undefined;
|
||||
if (!Array.isArray(catalogEntries)) {
|
||||
throw new Error("marketplace catalog entries are required");
|
||||
}
|
||||
return catalogEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function marketplaceEntryKey(
|
||||
entry: Pick<MarketplaceInstallInput, "id" | "type">,
|
||||
) {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function redactOutput(value: string): string {
|
||||
const lines = value.split(/\r?\n/).map((line) => {
|
||||
if (!SECRET_PATTERN.test(line)) return line;
|
||||
return line
|
||||
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
|
||||
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
|
||||
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
|
||||
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
|
||||
.replace(
|
||||
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
});
|
||||
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
|
||||
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
|
||||
new Promise<SpawnResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
env: options.env ?? process.env,
|
||||
shell: options.shell ?? platform() === "win32",
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
|
||||
child.kill("SIGTERM");
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
forceKillTimeout.unref?.();
|
||||
timeout.unref?.();
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
const result = {
|
||||
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
function normalizeTransport(value: string | undefined): string {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertUrl(value: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
const [rawName, ...rest] = args;
|
||||
const name = rawName?.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP marketplace install requires a server name");
|
||||
}
|
||||
let transportType = "stdio";
|
||||
const headers: Record<string, string> = {};
|
||||
const targetArgs: string[] = [];
|
||||
let parsingMarketplaceOptions = true;
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
const arg = rest[index];
|
||||
if (parsingMarketplaceOptions && arg === "--") {
|
||||
targetArgs.push(...rest.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
|
||||
const next = rest[index + 1]?.trim();
|
||||
if (!next) throw new Error("--transport requires a value");
|
||||
transportType = normalizeTransport(next);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const shouldParseHeader =
|
||||
parsingMarketplaceOptions ||
|
||||
normalizeTransport(transportType) !== "stdio";
|
||||
if (
|
||||
shouldParseHeader &&
|
||||
(arg === "--header" || arg?.startsWith("--header="))
|
||||
) {
|
||||
const rawHeader =
|
||||
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
|
||||
if (!rawHeader) throw new Error("--header requires a value");
|
||||
const separatorIndex = rawHeader.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
const headerName = rawHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
|
||||
if (!headerName || !headerValue) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
headers[headerName] = headerValue;
|
||||
continue;
|
||||
}
|
||||
parsingMarketplaceOptions = false;
|
||||
targetArgs.push(arg);
|
||||
}
|
||||
transportType = normalizeTransport(transportType);
|
||||
if (transportType === "stdio") {
|
||||
if (Object.keys(headers).length > 0) {
|
||||
throw new Error("Stdio MCP installs do not support request headers.");
|
||||
}
|
||||
const [command, ...commandArgs] = targetArgs;
|
||||
if (!command?.trim()) {
|
||||
throw new Error("Stdio MCP install requires a command");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
command,
|
||||
args: commandArgs.length > 0 ? commandArgs : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error("Remote MCP install requires exactly one URL");
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertUrl(url);
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUserInstructionRemovalTarget(input: {
|
||||
type: "skill" | "workflow";
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): string {
|
||||
const filePath = resolve(input.path);
|
||||
const searchPaths =
|
||||
input.type === "skill"
|
||||
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
|
||||
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
|
||||
const containingRoot = searchPaths.find((root) =>
|
||||
isInsidePath(filePath, root),
|
||||
);
|
||||
if (!containingRoot) {
|
||||
throw new Error(
|
||||
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
|
||||
);
|
||||
}
|
||||
const stats = statSync(filePath, { throwIfNoEntry: false });
|
||||
if (!stats?.isFile()) {
|
||||
throw new Error(`${input.type} file does not exist: ${filePath}`);
|
||||
}
|
||||
if (input.type === "workflow") {
|
||||
return filePath;
|
||||
}
|
||||
const skillDir = dirname(filePath);
|
||||
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
|
||||
}
|
||||
|
||||
export async function uninstallLocalPrimitive(
|
||||
args?: Record<string, unknown>,
|
||||
options: { workspaceRoot?: string } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const input = readLocalUninstallInput(args);
|
||||
if (input.type === "mcp") {
|
||||
const name = input.name ?? input.id;
|
||||
const response = deleteMcpServer(name);
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${name}.`,
|
||||
details: { mcp: response },
|
||||
};
|
||||
}
|
||||
if (input.type === "plugin") {
|
||||
const result = await uninstallLocalPlugin({
|
||||
name: input.path ? undefined : (input.name ?? input.id),
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${result.name}.`,
|
||||
details: result as unknown as JsonRecord,
|
||||
};
|
||||
}
|
||||
if (input.type === "skill" || input.type === "workflow") {
|
||||
if (!input.path) {
|
||||
throw new Error(`${input.type} uninstall requires a path.`);
|
||||
}
|
||||
const target = resolveUserInstructionRemovalTarget({
|
||||
type: input.type,
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const stats = statSync(target, { throwIfNoEntry: false });
|
||||
if (!stats) {
|
||||
throw new Error(`${input.type} target does not exist: ${target}`);
|
||||
}
|
||||
rmSync(target, { recursive: stats.isDirectory(), force: true });
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${input.name ?? basename(target)}.`,
|
||||
details: { path: target },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported local uninstall type: ${input.type}`);
|
||||
}
|
||||
|
||||
function hashSource(source: string): string {
|
||||
return createHash("sha256").update(source).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replace(/^@/, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return sanitized || "plugin";
|
||||
}
|
||||
|
||||
function sanitizeSkillSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._]+/g, "-")
|
||||
.replace(/^[.-]+|[.-]+$/g, "")
|
||||
.slice(0, 255);
|
||||
return sanitized || "skill";
|
||||
}
|
||||
|
||||
function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
const slug = source.trim();
|
||||
if (!isOfficialPluginSlug(slug)) return undefined;
|
||||
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
|
||||
return join(
|
||||
resolveClineDir(),
|
||||
"plugins",
|
||||
"_installed",
|
||||
"official",
|
||||
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "plugin") return false;
|
||||
const [source] = entry.install.args ?? [];
|
||||
if (!source) return false;
|
||||
const installPath = getOfficialPluginInstallPath(source);
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return (
|
||||
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string | undefined): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const addCandidate = (value: string | undefined) => {
|
||||
const normalized = sanitizeSkillSegment(value ?? "");
|
||||
if (normalized && normalized !== "skill") {
|
||||
candidates.add(normalized);
|
||||
}
|
||||
};
|
||||
addCandidate(entry.id);
|
||||
addCandidate(entry.name);
|
||||
const installArgs = entry.install.args ?? [];
|
||||
for (let index = 0; index < installArgs.length; index++) {
|
||||
const arg = installArgs[index];
|
||||
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
|
||||
addCandidate(installArgs[index + 1]);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const skillFilter = arg.split("@").at(1);
|
||||
if (skillFilter) {
|
||||
addCandidate(skillFilter);
|
||||
}
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function getGlobalSkillPaths(skillName: string): string[] {
|
||||
return [
|
||||
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
|
||||
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
|
||||
].filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function ensureGlobalSkillsDirWritable(): void {
|
||||
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
|
||||
try {
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
const probePath = join(
|
||||
skillsDir,
|
||||
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
writeFileSync(probePath, "", { flag: "wx" });
|
||||
unlinkSync(probePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return findInstalledGlobalSkillName(entry) !== undefined;
|
||||
}
|
||||
|
||||
function findInstalledGlobalSkillName(
|
||||
entry: MarketplaceInstallInput,
|
||||
): string | undefined {
|
||||
if (entry.type !== "skill") return undefined;
|
||||
const candidates = getSkillInstallCandidates(entry);
|
||||
return candidates.find((candidate) =>
|
||||
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasMatchingInventoryItem(
|
||||
items: unknown,
|
||||
entry: MarketplaceInstallInput,
|
||||
): boolean {
|
||||
if (!Array.isArray(items)) return false;
|
||||
const candidates = new Set([
|
||||
normalizeMatchValue(entry.id),
|
||||
normalizeMatchValue(entry.name),
|
||||
...(entry.install.args ?? []).map(normalizeMatchValue),
|
||||
]);
|
||||
candidates.delete("");
|
||||
return items.some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as JsonRecord;
|
||||
const values = [
|
||||
typeof record.name === "string" ? record.name : undefined,
|
||||
typeof record.id === "string" ? record.id : undefined,
|
||||
typeof record.path === "string" ? record.path : undefined,
|
||||
]
|
||||
.map(normalizeMatchValue)
|
||||
.filter(Boolean);
|
||||
return values.some((value) => candidates.has(value));
|
||||
});
|
||||
}
|
||||
|
||||
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "mcp") return false;
|
||||
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const response = readMcpServersResponse();
|
||||
const servers = Array.isArray(response.servers) ? response.servers : [];
|
||||
return servers.some((server) => {
|
||||
if (!server || typeof server !== "object") return false;
|
||||
const record = server as JsonRecord;
|
||||
return record.name === input.name;
|
||||
});
|
||||
}
|
||||
|
||||
function isMarketplaceEntryInstalled(
|
||||
entry: MarketplaceInstallInput,
|
||||
inventory?: JsonRecord,
|
||||
): boolean {
|
||||
try {
|
||||
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
|
||||
if (entry.type === "plugin") {
|
||||
return (
|
||||
isOfficialPluginInstalled(entry) ||
|
||||
hasMatchingInventoryItem(inventory?.plugins, entry)
|
||||
);
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return isGlobalSkillInstalled(entry);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutput(result: SpawnResult): string | undefined {
|
||||
const output = redactOutput(
|
||||
[result.stdout, result.stderr].filter(Boolean).join("\n"),
|
||||
);
|
||||
return output.trim().length > 0 ? output.trim() : undefined;
|
||||
}
|
||||
|
||||
async function installSkill(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
if (isGlobalSkillInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
ensureGlobalSkillsDirWritable();
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
...(entry.install.args ?? []),
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
const output = commandOutput(result);
|
||||
if (/\bFailed to install\b/i.test(output ?? "")) {
|
||||
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
|
||||
}
|
||||
if (!isGlobalSkillInstalled(entry)) {
|
||||
throw new Error(
|
||||
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Plugin marketplace installs currently support exactly one source argument.",
|
||||
);
|
||||
}
|
||||
if (isOfficialPluginInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry, spawnCommand);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
let mcpDetails: JsonRecord | undefined;
|
||||
const result = await uninstallCoreMarketplaceEntry(
|
||||
entry satisfies MarketplaceEntryInput,
|
||||
{
|
||||
deleteMcpServer: (name) => {
|
||||
mcpDetails = deleteMcpServer(name);
|
||||
},
|
||||
spawnCommand: (command, commandArgs) =>
|
||||
spawnCommand(command, commandArgs),
|
||||
},
|
||||
);
|
||||
return {
|
||||
...(result satisfies MarketplaceActionResult),
|
||||
details: mcpDetails ? { mcp: mcpDetails } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return installMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return uninstallMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export function listMarketplaceInstalledEntries(
|
||||
args?: Record<string, unknown>,
|
||||
inventory?: JsonRecord,
|
||||
): MarketplaceInstallStatusResult {
|
||||
const entries = readInstallInputList(args);
|
||||
const installedKeys = entries
|
||||
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
|
||||
.map(marketplaceEntryKey);
|
||||
return { installedKeys };
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return installMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return uninstallMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
export function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function setMcpServerDisabled(
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
const name = String(input.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const previousName = String(
|
||||
input.previousName ?? input.previous_name ?? "",
|
||||
).trim();
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
}
|
||||
: {
|
||||
transport: {
|
||||
type: transportType === "sse" ? "sse" : "streamableHttp",
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
};
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function deleteMcpServer(name: string): JsonRecord {
|
||||
if (!name) throw new Error("server name is required");
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFetchHandler } from "./server";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
function createTestServer() {
|
||||
return {
|
||||
port: 3126,
|
||||
upgrade: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function createHandler(onShutdown = vi.fn()) {
|
||||
return createFetchHandler({} as SidecarContext, onShutdown);
|
||||
}
|
||||
|
||||
describe("sidecar HTTP origin checks", () => {
|
||||
it("rejects cross-origin shutdown preflight requests", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/shutdown", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
"access-control-request-method": "POST",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(response?.headers.get("access-control-allow-origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects cross-origin shutdown POST requests", async () => {
|
||||
const onShutdown = vi.fn();
|
||||
const server = createTestServer();
|
||||
const response = await createHandler(onShutdown)(
|
||||
new Request("http://127.0.0.1:3126/shutdown", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(onShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-origin websocket upgrades", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/transport", {
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
expect(server.upgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows desktop webview origins in preflight responses", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/api/marketplace/catalog", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(204);
|
||||
expect(response?.headers.get("access-control-allow-origin")).toBe(
|
||||
"tauri://localhost",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_HOST,
|
||||
SIDECAR_MODE,
|
||||
SIDECAR_PORT,
|
||||
type SidecarContext,
|
||||
@@ -14,6 +16,57 @@ type SidecarServer = {
|
||||
upgrade(req: Request): boolean;
|
||||
};
|
||||
|
||||
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
|
||||
// the sidecar runs inside a container). Origin validation itself stays on.
|
||||
const EXTRA_TRUSTED_ORIGINS = (process.env.CLINE_SIDECAR_TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const TRUSTED_BROWSER_ORIGINS = new Set([
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://localhost:3125",
|
||||
"http://127.0.0.1:3125",
|
||||
...EXTRA_TRUSTED_ORIGINS,
|
||||
]);
|
||||
|
||||
const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
function readOrigin(req: Request): string | undefined {
|
||||
const origin = req.headers.get("origin")?.trim();
|
||||
return origin ? origin : undefined;
|
||||
}
|
||||
|
||||
function isTrustedRequestOrigin(req: Request): boolean {
|
||||
const origin = readOrigin(req);
|
||||
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
|
||||
}
|
||||
|
||||
function corsHeaders(req: Request): Record<string, string> {
|
||||
const origin = readOrigin(req);
|
||||
return {
|
||||
"access-control-allow-headers": "accept, content-type",
|
||||
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
|
||||
? {
|
||||
"access-control-allow-origin": origin,
|
||||
vary: "Origin",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonHeaders(req: Request): Record<string, string> {
|
||||
return {
|
||||
...JSON_HEADERS,
|
||||
...corsHeaders(req),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -27,6 +80,29 @@ function jsonResponse(
|
||||
return JSON.stringify({ type: "response", id, ok, result, error });
|
||||
}
|
||||
|
||||
function createJsonResponse(
|
||||
req: Request,
|
||||
body: unknown,
|
||||
status = 200,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
|
||||
const EMPTY_MARKETPLACE_CATALOG = {
|
||||
version: 1,
|
||||
counts: {
|
||||
total: 0,
|
||||
plugins: 0,
|
||||
skills: 0,
|
||||
mcps: 0,
|
||||
},
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bun HTTP + WebSocket server
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -48,7 +124,7 @@ export function startServer(
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
server = BunRuntime.serve({
|
||||
hostname: "127.0.0.1",
|
||||
hostname: SIDECAR_HOST,
|
||||
port: candidate,
|
||||
fetch: createFetchHandler(ctx, onShutdown),
|
||||
websocket: createWebSocketHandler(ctx),
|
||||
@@ -66,13 +142,20 @@ export function startServer(
|
||||
return { port: server.port };
|
||||
}
|
||||
|
||||
function createFetchHandler(
|
||||
export function createFetchHandler(
|
||||
_ctx: SidecarContext,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
) {
|
||||
return async (req: Request, server: SidecarServer) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return new Response(null, { status: 204, headers: corsHeaders(req) });
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -80,15 +163,39 @@ function createFetchHandler(
|
||||
mode: SIDECAR_MODE,
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
{ headers: jsonHeaders(req) },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/transport" && server.upgrade(req)) {
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
server.upgrade(req)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(req, await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(req, {
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/shutdown" && req.method === "POST") {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 403,
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void onShutdown?.("code_sidecar_shutdown_endpoint")
|
||||
.catch((error) => {
|
||||
@@ -101,7 +208,7 @@ function createFetchHandler(
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: jsonHeaders(req),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export function discoverChatSessions(
|
||||
prompt,
|
||||
messages: session.messages,
|
||||
});
|
||||
const persistedMetadata = store.get(sessionId)?.metadata;
|
||||
out.push({
|
||||
sessionId,
|
||||
status: session.status,
|
||||
@@ -68,7 +69,10 @@ export function discoverChatSessions(
|
||||
prompt,
|
||||
startedAt: String(session.startedAt),
|
||||
endedAt: session.endedAt ? String(session.endedAt) : undefined,
|
||||
metadata: { title: resolvedTitle },
|
||||
metadata: {
|
||||
...(persistedMetadata ?? {}),
|
||||
title: resolvedTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AgentToolContext,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -103,6 +104,7 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
@@ -113,4 +115,8 @@ export type BunRuntimeApi = {
|
||||
export const BunRuntime = (globalThis as { Bun?: BunRuntimeApi }).Bun;
|
||||
|
||||
export const SIDECAR_PORT = Number(process.env.CLINE_SIDECAR_PORT) || 3126;
|
||||
// Loopback-only by default. Set CLINE_SIDECAR_HOST=0.0.0.0 to accept
|
||||
// connections from outside the local host (e.g. Docker port publishing).
|
||||
export const SIDECAR_HOST =
|
||||
process.env.CLINE_SIDECAR_HOST?.trim() || "127.0.0.1";
|
||||
export const SIDECAR_MODE = "sidecar";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Bun/JavaScriptCore requires JIT + shared executable memory under the hardened runtime -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -179,30 +179,37 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn desktop_backend_binary_name() -> String {
|
||||
fn desktop_backend_binary_names() -> Vec<String> {
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
let bundled_name = format!("code-sidecar{extension}");
|
||||
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
|
||||
if target_triple.is_empty() {
|
||||
return "code-sidecar".to_string();
|
||||
return vec![bundled_name];
|
||||
}
|
||||
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
format!("code-sidecar-{target_triple}{extension}")
|
||||
vec![
|
||||
bundled_name,
|
||||
format!("code-sidecar-{target_triple}{extension}"),
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
|
||||
if cfg!(debug_assertions) {
|
||||
return None;
|
||||
}
|
||||
let binary_name = desktop_backend_binary_name();
|
||||
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
let candidates = [
|
||||
explicit,
|
||||
Some(
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = explicit {
|
||||
candidates.push(path);
|
||||
}
|
||||
|
||||
for binary_name in desktop_backend_binary_names() {
|
||||
candidates.push(
|
||||
PathBuf::from(&context.workspace_root)
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
@@ -210,17 +217,23 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
.join(&binary_name),
|
||||
),
|
||||
current_exe
|
||||
);
|
||||
if let Some(path) = current_exe
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name))),
|
||||
current_exe.as_ref().and_then(|path| {
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
|
||||
{
|
||||
candidates.push(path);
|
||||
}
|
||||
if let Some(path) = current_exe.as_ref().and_then(|path| {
|
||||
path.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(|parent| parent.join("Resources").join(&binary_name))
|
||||
}),
|
||||
];
|
||||
candidates.into_iter().flatten().find(|path| path.exists())
|
||||
}) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -33,6 +33,10 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const MARKETPLACE_CATALOG_URL =
|
||||
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
const EMPTY_MARKETPLACE_CATALOG = {
|
||||
version: 1,
|
||||
counts: {
|
||||
total: 0,
|
||||
plugins: 0,
|
||||
skills: 0,
|
||||
mcps: 0,
|
||||
},
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(MARKETPLACE_CATALOG_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return Response.json({
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
|
||||
});
|
||||
}
|
||||
return Response.json(await response.json());
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
...EMPTY_MARKETPLACE_CATALOG,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,145 @@
|
||||
@import "@fontsource-variable/geist";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-geist-sans: "Geist Variable";
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
--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);
|
||||
--font-desktop-sans: "Schibsted Grotesk Variable";
|
||||
--font-desktop-mono: "Azeret Mono";
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.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);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.11 0.005 260);
|
||||
--sidebar-foreground: oklch(0.85 0 0);
|
||||
--sidebar-primary: oklch(0.75 0.12 165);
|
||||
--sidebar-primary-foreground: oklch(0.13 0.005 260);
|
||||
--sidebar-accent: oklch(0.18 0.008 260);
|
||||
--sidebar-accent-foreground: oklch(0.93 0 0);
|
||||
--sidebar-border: oklch(0.22 0.005 260);
|
||||
--sidebar-ring: oklch(0.75 0.12 165);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.75 0.12 165);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), "Geist Fallback";
|
||||
--font-mono: var(--font-geist-mono), "Geist Mono Fallback";
|
||||
--font-sans: var(--font-desktop-sans), sans-serif;
|
||||
--font-mono:
|
||||
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-weight-normal: 480;
|
||||
--font-weight-medium: 560;
|
||||
--font-weight-semibold: 640;
|
||||
--font-weight-bold: 640;
|
||||
--text-step-1: 12px;
|
||||
--text-step-1--line-height: 16px;
|
||||
--text-step-1--letter-spacing: 0.0025em;
|
||||
--text-step-2: 14px;
|
||||
--text-step-2--line-height: 20px;
|
||||
--text-step-2--letter-spacing: 0em;
|
||||
--text-step-3: 16px;
|
||||
--text-step-3--line-height: 24px;
|
||||
--text-step-3--letter-spacing: 0em;
|
||||
--text-step-4: 18px;
|
||||
--text-step-4--line-height: 26px;
|
||||
--text-step-4--letter-spacing: -0.0025em;
|
||||
--text-step-5: 20px;
|
||||
--text-step-5--line-height: 28px;
|
||||
--text-step-5--letter-spacing: -0.005em;
|
||||
--text-step-6: 24px;
|
||||
--text-step-6--line-height: 30px;
|
||||
--text-step-6--letter-spacing: -0.00625em;
|
||||
--text-step-7: 28px;
|
||||
--text-step-7--line-height: 36px;
|
||||
--text-step-7--letter-spacing: -0.0075em;
|
||||
--text-step-8: 35px;
|
||||
--text-step-8--line-height: 40px;
|
||||
--text-step-8--letter-spacing: -0.01em;
|
||||
--text-step-9: 60px;
|
||||
--text-step-9--line-height: 60px;
|
||||
--text-step-9--letter-spacing: -0.025em;
|
||||
--text-xs: var(--text-step-1);
|
||||
--text-xs--line-height: var(--text-step-1--line-height);
|
||||
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
|
||||
--text-sm: var(--text-step-2);
|
||||
--text-sm--line-height: var(--text-step-2--line-height);
|
||||
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
|
||||
--text-base: var(--text-step-3);
|
||||
--text-base--line-height: var(--text-step-3--line-height);
|
||||
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
|
||||
--text-lg: var(--text-step-4);
|
||||
--text-lg--line-height: var(--text-step-4--line-height);
|
||||
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
|
||||
--text-xl: var(--text-step-5);
|
||||
--text-xl--line-height: var(--text-step-5--line-height);
|
||||
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
|
||||
--text-2xl: var(--text-step-6);
|
||||
--text-2xl--line-height: var(--text-step-6--line-height);
|
||||
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
|
||||
--text-3xl: var(--text-step-7);
|
||||
--text-3xl--line-height: var(--text-step-7--line-height);
|
||||
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
|
||||
--text-4xl: var(--text-step-8);
|
||||
--text-4xl--line-height: var(--text-step-8--line-height);
|
||||
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
|
||||
--text-6xl: var(--text-step-9);
|
||||
--text-6xl--line-height: var(--text-step-9--line-height);
|
||||
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -99,13 +192,22 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply m-0 bg-background text-foreground;
|
||||
@apply m-0 bg-background text-base font-normal text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
.markdown * {
|
||||
@apply text-sm;
|
||||
@apply text-sm leading-relaxed;
|
||||
}
|
||||
.markdown + .markdown {
|
||||
@apply mt-2;
|
||||
}
|
||||
.markdown p {
|
||||
@apply my-2 first:mt-0 last:mb-0;
|
||||
}
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
@@ -163,3 +265,73 @@
|
||||
::selection {
|
||||
background: oklch(0.75 0.12 165 / 0.25);
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(14%, -10%) rotate(18deg) scale(1.25);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-4%, 6%) rotate(-6deg) scale(1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-12%, -4%) rotate(-16deg) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* Curtain ribbons: sway side-to-side while skewing and stretching, like an
|
||||
aurora curtain rippling. Ribbons are bottom-anchored (transform-origin
|
||||
bottom), so skew/scale fan out from the horizon. */
|
||||
@keyframes aurora-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0) skewX(0deg) scaleY(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
20% {
|
||||
transform: translateX(4%) skewX(8deg) scaleY(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
transform: translateX(-3%) skewX(-10deg) scaleY(0.9);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: translateX(5%) skewX(12deg) scaleY(1.25);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Traveling wave: a 200%-wide striped sheet slides left by half its width,
|
||||
looping seamlessly, while bobbing vertically — bands visibly roll across. */
|
||||
@keyframes aurora-flow {
|
||||
0% {
|
||||
transform: translateX(0) translateY(0) skewX(-6deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-12.5%) translateY(-4%) skewX(4deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-25%) translateY(2%) skewX(-3deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-37.5%) translateY(-5%) skewX(6deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(0) skewX(-6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,17 +22,21 @@ import {
|
||||
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
import { SettingsView } from "@/components/views/settings/settings-view";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
import { useSessionHistory } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
getSessionMetadataTitle,
|
||||
type SessionHistoryItem,
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
|
||||
function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
@@ -70,17 +74,24 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<"chat" | "diff" | "settings">("chat");
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [threads, setThreads] = useState<Thread[]>(() => [
|
||||
{ id: makeThreadId() },
|
||||
]);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string>(
|
||||
() => threads[0]?.id,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncHubTheme();
|
||||
return watchSystemHubTheme();
|
||||
}, []);
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
const id = makeThreadId();
|
||||
setThreads((prev) => [...prev, { id }]);
|
||||
setActiveThreadId(id);
|
||||
setView("chat");
|
||||
}, []);
|
||||
|
||||
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
|
||||
@@ -98,6 +109,7 @@ export default function Home() {
|
||||
return [...prev, { id: threadId, historySession: session }];
|
||||
});
|
||||
setActiveThreadId(threadId);
|
||||
setView("chat");
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
@@ -176,6 +188,12 @@ export default function Home() {
|
||||
?.sessionId ?? null;
|
||||
const activeThread =
|
||||
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
|
||||
const sessionHistory = useSessionHistory({
|
||||
activeSessionId: activeHistorySessionId,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
onOpenSession: handleOpenSession,
|
||||
onUpdateSessionMetadata: handleUpdateSessionMetadata,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -188,13 +206,18 @@ export default function Home() {
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{activeThread ? (
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
@@ -241,6 +264,7 @@ function ChatThreadPane({
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState,
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
config,
|
||||
@@ -454,7 +478,12 @@ function ChatThreadPane({
|
||||
async (preferredWorkspace?: string) => {
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces(results);
|
||||
setWorkspaces((current) =>
|
||||
current.length === results.length &&
|
||||
current.every((workspace, index) => workspace === results[index])
|
||||
? current
|
||||
: results,
|
||||
);
|
||||
} finally {
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
@@ -595,6 +624,26 @@ function ChatThreadPane({
|
||||
await sendPrompt(trimmed, toSend);
|
||||
}, [pendingAttachments, promptInput, sendPrompt]);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
|
||||
setConfig((prev) => {
|
||||
if (
|
||||
prev.thinking === next.thinking &&
|
||||
prev.reasoningEffort === next.reasoningEffort
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
thinking: next.thinking,
|
||||
reasoningEffort:
|
||||
next.thinking === false ? undefined : next.reasoningEffort,
|
||||
};
|
||||
});
|
||||
},
|
||||
[setConfig],
|
||||
);
|
||||
|
||||
const handleUndoQueuedPrompt = useCallback(
|
||||
async (item: PromptInQueue) => {
|
||||
const removed = await removePromptInQueue(item.id);
|
||||
@@ -777,6 +826,8 @@ function ChatThreadPane({
|
||||
const displayedIsSwitching = hideDeletedSessionUi
|
||||
? false
|
||||
: isHydratingSession;
|
||||
const isWelcomeState =
|
||||
displayedMessages.length === 0 && !displayedIsSwitching;
|
||||
|
||||
const handleRenameTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
@@ -827,9 +878,7 @@ function ChatThreadPane({
|
||||
workspaceRoot: resolvedWorkspaceRoot,
|
||||
workspaces,
|
||||
listWorkspaces,
|
||||
refreshWorkspaces: async () => {
|
||||
await refreshWorkspaces();
|
||||
},
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
}),
|
||||
@@ -851,8 +900,17 @@ function ChatThreadPane({
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center gap-3 bg-background text-foreground">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{chatTransportState !== "connected" ? "Connecting..." : "Loading..."}
|
||||
{chatTransportState === "unavailable"
|
||||
? "Desktop backend unavailable"
|
||||
: chatTransportState !== "connected"
|
||||
? "Connecting..."
|
||||
: "Loading..."}
|
||||
</p>
|
||||
{chatTransportError ? (
|
||||
<p className="max-w-xl px-6 text-center text-xs text-muted-foreground">
|
||||
{chatTransportError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -878,6 +936,7 @@ function ChatThreadPane({
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
showSessionActions={!isWelcomeState}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
@@ -959,6 +1018,7 @@ function ChatThreadPane({
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
@@ -996,8 +1056,10 @@ function ChatThreadPane({
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
reasoningEffort={config.reasoningEffort}
|
||||
status={status}
|
||||
summary={summary}
|
||||
thinking={config.thinking}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ type AgentHeaderProps = {
|
||||
canDeleteSession?: boolean;
|
||||
deletingSession?: boolean;
|
||||
onOpenDiff?: () => void;
|
||||
showSessionActions?: boolean;
|
||||
status?: ChatSessionStatus;
|
||||
diff?: {
|
||||
additions: number;
|
||||
@@ -41,6 +42,7 @@ export function AgentHeader({
|
||||
canDeleteSession,
|
||||
deletingSession,
|
||||
onOpenDiff,
|
||||
showSessionActions = true,
|
||||
status,
|
||||
diff,
|
||||
}: AgentHeaderProps) {
|
||||
@@ -80,7 +82,7 @@ export function AgentHeader({
|
||||
const triggerDeleteSession = () => onDeleteSession?.();
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-card px-4">
|
||||
<header className="flex h-12 items-center justify-between px-4">
|
||||
{/* Left: thread title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -164,34 +166,35 @@ export function AgentHeader({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* DIFF */}
|
||||
<Button
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges ? "hover:bg-secondary/80" : "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-primary">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
{showSessionActions ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges
|
||||
? "hover:bg-secondary/80"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Star {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
// Big blurred gradient blobs that slowly drift/rotate to fake an aurora.
|
||||
// Each entry is [positionClasses, gradient, animationDuration, animationDelay].
|
||||
const BLOBS: Array<[string, string, string, string]> = [
|
||||
[
|
||||
"left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.55 0.2 278 / 0.55), transparent 70%)",
|
||||
"16s",
|
||||
"0s",
|
||||
],
|
||||
[
|
||||
"left-[25%] bottom-[-50%] w-[60%] h-[90%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.65 0.19 200 / 0.4), transparent 70%)",
|
||||
"22s",
|
||||
"-6s",
|
||||
],
|
||||
[
|
||||
"right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.6 0.18 310 / 0.5), transparent 70%)",
|
||||
"19s",
|
||||
"-12s",
|
||||
],
|
||||
[
|
||||
"left-[10%] bottom-[-30%] w-[80%] h-[60%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.75 0.13 340 / 0.35), transparent 70%)",
|
||||
"26s",
|
||||
"-3s",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* A decorative aurora background built entirely from CSS: blurred gradient
|
||||
* blobs drifting on keyframe animations, plus twinkling star dots. No canvas,
|
||||
* no WebGL, no per-frame JS. Absolutely positioned to fill its nearest
|
||||
* positioned parent; pointer events pass through.
|
||||
*
|
||||
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
|
||||
*/
|
||||
export function AuroraBackground({ starCount = 90 }: { starCount?: number }) {
|
||||
// Random star field, generated once per mount.
|
||||
const stars = useMemo<Star[]>(
|
||||
() =>
|
||||
Array.from({ length: starCount }, () => {
|
||||
// Squared skew biases stars toward the bottom, where the glow lives.
|
||||
const r = Math.random();
|
||||
return {
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${100 - (1 - r * r) * 45}%`,
|
||||
size: Math.random() < 0.15 ? 3 : Math.random() < 0.5 ? 2 : 1,
|
||||
delay: `${Math.random() * 4}s`,
|
||||
duration: `${1.5 + Math.random() * 3.5}s`,
|
||||
opacity: 0.3 + Math.random() * 0.6,
|
||||
};
|
||||
}),
|
||||
[starCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{BLOBS.map(([position, gradient, duration, delay], idx) => (
|
||||
<div
|
||||
key={`blob${idx}`}
|
||||
className={`absolute blur-3xl animate-[aurora-drift_linear_infinite] ${position}`}
|
||||
style={{
|
||||
background: gradient,
|
||||
animationDuration: duration,
|
||||
animationDelay: delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{stars.map((s, idx) => (
|
||||
<span
|
||||
key={`star${idx}`}
|
||||
className="absolute rounded-none bg-[#b8f3ee] animate-[aurora-twinkle_ease-in-out_infinite]"
|
||||
style={{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
opacity: s.opacity,
|
||||
animationDelay: s.delay,
|
||||
animationDuration: s.duration,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer cursor-pointer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer cursor-pointer data-[state=checked]:bg-primary/20 data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "@/components/ui/combobox";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
@@ -66,19 +66,62 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-2.5-pro"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
};
|
||||
|
||||
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-2.5-pro"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
};
|
||||
|
||||
type ReasoningEffort = NonNullable<ChatSessionConfig["reasoningEffort"]>;
|
||||
type ReasoningEffortOption = {
|
||||
label: string;
|
||||
value: "none" | ReasoningEffort;
|
||||
};
|
||||
|
||||
const DEFAULT_REASONING_EFFORT: ReasoningEffortOption = {
|
||||
label: "Low",
|
||||
value: "low",
|
||||
};
|
||||
|
||||
const EFFORT_LEVELS: ReasoningEffortOption[] = [
|
||||
{ label: "None", value: "none" },
|
||||
DEFAULT_REASONING_EFFORT,
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Extra", value: "xhigh" },
|
||||
];
|
||||
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
|
||||
const PROMPT_INPUT_FOCUSED_ROWS = 5;
|
||||
|
||||
function resolveEffortIndex(
|
||||
thinking: ChatSessionConfig["thinking"],
|
||||
reasoningEffort: ChatSessionConfig["reasoningEffort"],
|
||||
): number {
|
||||
if (thinking === false) {
|
||||
return 0;
|
||||
}
|
||||
const index = EFFORT_LEVELS.findIndex(
|
||||
(option) => option.value === reasoningEffort,
|
||||
);
|
||||
return index >= 0 ? index : 1;
|
||||
}
|
||||
|
||||
function buildReasoningConfig(
|
||||
option: ReasoningEffortOption,
|
||||
): Pick<ChatSessionConfig, "thinking" | "reasoningEffort"> {
|
||||
if (option.value === "none") {
|
||||
return { thinking: false, reasoningEffort: undefined };
|
||||
}
|
||||
return { thinking: true, reasoningEffort: option.value };
|
||||
}
|
||||
|
||||
function hasReasoningCapability(
|
||||
providerReasoningModels: Record<string, string[]>,
|
||||
provider: string,
|
||||
@@ -149,12 +192,17 @@ type ChatInputBarProps = {
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: "act" | "plan";
|
||||
thinking: ChatSessionConfig["thinking"];
|
||||
reasoningEffort: ChatSessionConfig["reasoningEffort"];
|
||||
gitBranch: string;
|
||||
promptInput: string;
|
||||
onPromptInputChange: (value: string) => void;
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModeToggle: () => void;
|
||||
onReasoningChange: (
|
||||
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
|
||||
) => void;
|
||||
onRefreshGitBranch: () => void;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
@@ -183,12 +231,15 @@ export function ChatInputBar({
|
||||
provider,
|
||||
model,
|
||||
mode,
|
||||
thinking,
|
||||
reasoningEffort,
|
||||
gitBranch,
|
||||
promptInput,
|
||||
onPromptInputChange,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModeToggle,
|
||||
onReasoningChange,
|
||||
onRefreshGitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
@@ -219,10 +270,9 @@ export function ChatInputBar({
|
||||
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
|
||||
);
|
||||
const canSend = hasDraft;
|
||||
const effortLevels = ["Low", "Medium", "High"] as const;
|
||||
const [effortIndex, setEffortIndex] = useState(1);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [promptInputFocused, setPromptInputFocused] = useState(false);
|
||||
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
|
||||
@@ -258,13 +308,35 @@ export function ChatInputBar({
|
||||
}
|
||||
return `${total.toLocaleString()} tokens`;
|
||||
}, [summary.tokensIn, summary.tokensOut]);
|
||||
const effortLabel = effortLevels[effortIndex];
|
||||
const effortIndex = useMemo(
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
[reasoningEffort, thinking],
|
||||
);
|
||||
const effortLabel = modelSupportsReasoning
|
||||
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
|
||||
: "None";
|
||||
const handleEffortCycle = useCallback(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
return;
|
||||
}
|
||||
setEffortIndex((current) => (current + 1) % effortLevels.length);
|
||||
}, [effortLevels.length, modelSupportsReasoning]);
|
||||
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
|
||||
if (!nextOption) {
|
||||
return;
|
||||
}
|
||||
onReasoningChange(buildReasoningConfig(nextOption));
|
||||
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
if (thinking !== false || reasoningEffort !== undefined) {
|
||||
onReasoningChange({ thinking: false, reasoningEffort: undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (thinking === undefined && reasoningEffort === undefined) {
|
||||
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
|
||||
}
|
||||
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
|
||||
|
||||
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
|
||||
setEditingQueuedPromptId(item.id);
|
||||
@@ -330,20 +402,6 @@ export function ChatInputBar({
|
||||
}
|
||||
}, [cancelQueuedPromptEdit, editingQueuedPromptId, promptsInQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.style.height = "0px";
|
||||
const styles = window.getComputedStyle(input);
|
||||
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
|
||||
const maxHeight = lineHeight * 10;
|
||||
const nextHeight = Math.min(input.scrollHeight, maxHeight);
|
||||
input.style.height = `${nextHeight}px`;
|
||||
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const nextMention = getActiveMention(promptInput, cursorIndex);
|
||||
setActiveMention(nextMention);
|
||||
@@ -748,7 +806,7 @@ export function ChatInputBar({
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
|
||||
<textarea
|
||||
className="max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onChange={(e) => {
|
||||
onPromptInputChange(e.target.value);
|
||||
setCursorIndex(
|
||||
@@ -760,6 +818,8 @@ export function ChatInputBar({
|
||||
e.currentTarget.selectionStart ?? promptInput.length,
|
||||
)
|
||||
}
|
||||
onBlur={() => setPromptInputFocused(false)}
|
||||
onFocus={() => setPromptInputFocused(true)}
|
||||
onKeyDown={(e) => {
|
||||
// Slash command menu takes priority when open.
|
||||
if (slashOpen && filteredSlashCommands.length > 0) {
|
||||
@@ -835,10 +895,14 @@ export function ChatInputBar({
|
||||
placeholder={
|
||||
isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for workflow or @ to attach files"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
}
|
||||
ref={promptInputRef}
|
||||
rows={1}
|
||||
rows={
|
||||
promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
}
|
||||
value={promptInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,22 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
FileIcon,
|
||||
FileSearch,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
SplitIcon,
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { parseApplyPatchInput } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../ui/markdown";
|
||||
import { normalizeTitle } from "../../utils";
|
||||
@@ -36,7 +40,11 @@ import { WelcomeScreen } from "./welcome-chat";
|
||||
type ChatMessagesProps = {
|
||||
sessionId: string | null;
|
||||
status: ChatSessionStatus;
|
||||
chatTransportState?: "connecting" | "reconnecting" | "connected";
|
||||
chatTransportState?:
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
isSessionSwitching?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -365,10 +373,10 @@ function ChatMessagesImpl({
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
className="h-full min-h-0 min-w-0 overflow-y-auto"
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full px-6 py-6">
|
||||
<div className="relative mx-auto w-full h-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
{showIdleDetails ? (
|
||||
<WelcomeScreen
|
||||
provider={provider}
|
||||
@@ -377,7 +385,7 @@ function ChatMessagesImpl({
|
||||
quickActions={[]}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full h-full">
|
||||
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
@@ -474,7 +482,9 @@ function ChatMessagesImpl({
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{chatTransportState === "reconnecting"
|
||||
? "Reconnecting chat..."
|
||||
: "Connecting chat..."}
|
||||
: chatTransportState === "unavailable"
|
||||
? "Chat backend unavailable"
|
||||
: "Connecting chat..."}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
@@ -569,7 +579,7 @@ function ToolApprovalPanel({
|
||||
Request {item.requestId}
|
||||
{item.iteration != null ? ` · Iteration ${item.iteration}` : ""}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
|
||||
<pre className="mt-2 max-h-44 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
|
||||
{formatApprovalInput(item.input)}
|
||||
</pre>
|
||||
{error ? (
|
||||
@@ -722,6 +732,17 @@ function MessageBubble({
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
const checkpoint = message.meta?.checkpoint;
|
||||
const shouldRenderAssistantActions =
|
||||
message.role === "assistant" &&
|
||||
!isStreaming &&
|
||||
!isError &&
|
||||
Boolean(onCopyRawText || onForkSession);
|
||||
const shouldRenderUserActions =
|
||||
isUser && Boolean(onCopyRawText || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
const hiddenActionButtonsClassName =
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
|
||||
|
||||
if (message.role === "tool") {
|
||||
return <ToolMessageBlock message={message} />;
|
||||
@@ -732,71 +753,106 @@ function MessageBubble({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex", isUser ? "justify-end" : "justify-start w-full")}
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 pl-3 text-sm",
|
||||
isUser && "bg-card text-foreground/80 max-w-[50%]",
|
||||
!isUser && !isError && "text-foreground w-full",
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[50%] flex-col items-end gap-1",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
{isStreaming && message.role === "assistant" ? (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap">
|
||||
{normalizedContent || " "}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<MemoizedMarkdown
|
||||
content={normalizedContent || " "}
|
||||
id={message.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isUser && checkpoint ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{wasCopied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{isStreaming && message.role === "assistant" ? (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{normalizedContent || " "}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={normalizedContent || " "}
|
||||
id={message.id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{shouldRenderUserActions ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-6 items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-2",
|
||||
keepUserActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
Undo
|
||||
</Button>
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied ? "Copied user message" : "Copy user message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label="Restore checkpoint"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
title="Restore checkpoint"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
@@ -805,30 +861,61 @@ function MessageBubble({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!isUser &&
|
||||
!isError &&
|
||||
!isStreaming &&
|
||||
message.role === "assistant" &&
|
||||
onForkSession ? (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session — copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
</Button>
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Fork session"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session - copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">
|
||||
{forkError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -850,17 +937,18 @@ function ReasoningBlock({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="my-2">
|
||||
<Button
|
||||
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
Thinking
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="mt-1 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{displayContent}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -878,6 +966,10 @@ type ToolPayload = {
|
||||
type ToolSummary = {
|
||||
label: string;
|
||||
details: string[];
|
||||
diff?: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
};
|
||||
|
||||
function pruneRequestMap<T extends string>(
|
||||
@@ -966,7 +1058,12 @@ function classifyTool(
|
||||
].includes(normalized)
|
||||
)
|
||||
return "exploration";
|
||||
if (["editor", "edit_file", "edit"].includes(normalized)) return "file-edit";
|
||||
if (
|
||||
["editor", "edit_file", "edit", "apply_patch", "apply-patch"].includes(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return "file-edit";
|
||||
if (["bash", "run_commands"].includes(normalized)) return "bash";
|
||||
if (["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized))
|
||||
return "spawn";
|
||||
@@ -985,6 +1082,62 @@ function asStringArray(value: unknown): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* read_files accepts many input shapes: { files: [{ path }] }, { files: path },
|
||||
* { file_paths: [...] }, { paths: [...] }, a bare request, an array, or a string.
|
||||
*/
|
||||
function extractReadFilePaths(input: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
out.push(value);
|
||||
return;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (record && typeof record.path === "string" && record.path.length > 0) {
|
||||
out.push(record.path);
|
||||
}
|
||||
};
|
||||
const record = asRecord(input);
|
||||
const candidates =
|
||||
record?.files ?? record?.file_paths ?? record?.paths ?? record ?? input;
|
||||
if (Array.isArray(candidates)) {
|
||||
for (const candidate of candidates) {
|
||||
push(candidate);
|
||||
}
|
||||
} else {
|
||||
push(candidates);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* run_commands entries can be shell strings or structured { command, args }.
|
||||
*/
|
||||
function extractCommands(input: unknown): string[] {
|
||||
const inputObject = asRecord(input);
|
||||
const raw = Array.isArray(inputObject?.commands)
|
||||
? inputObject.commands
|
||||
: typeof inputObject?.command === "string"
|
||||
? [inputObject.command]
|
||||
: typeof input === "string"
|
||||
? [input]
|
||||
: [];
|
||||
const out: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) {
|
||||
out.push(entry);
|
||||
continue;
|
||||
}
|
||||
const record = asRecord(entry);
|
||||
if (record && typeof record.command === "string") {
|
||||
const args = asStringArray(record.args);
|
||||
out.push([record.command, ...args].join(" "));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toDisplayPath(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts.at(-1) || path;
|
||||
@@ -1025,10 +1178,10 @@ function buildToolSummary(
|
||||
const inputObject = asRecord(input);
|
||||
|
||||
if (["read_files", "file_read", "file-read"].includes(normalized)) {
|
||||
const files = asStringArray(inputObject?.file_paths);
|
||||
const files = extractReadFilePaths(input);
|
||||
if (files.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(files.length, "file")}`,
|
||||
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
|
||||
details: files.map(
|
||||
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
|
||||
),
|
||||
@@ -1047,14 +1200,8 @@ function buildToolSummary(
|
||||
}
|
||||
|
||||
if (["run_commands", "bash"].includes(normalized)) {
|
||||
const commands = asStringArray(inputObject?.commands);
|
||||
if (commands.length === 1) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${commands[0]}`,
|
||||
details: [commands[0]],
|
||||
};
|
||||
}
|
||||
if (commands.length > 1) {
|
||||
const commands = extractCommands(input);
|
||||
if (commands.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
|
||||
details: commands.map((command) => command.trim()),
|
||||
@@ -1084,9 +1231,44 @@ function buildToolSummary(
|
||||
}
|
||||
}
|
||||
|
||||
if (["apply_patch", "apply-patch"].includes(normalized)) {
|
||||
const patchText =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: typeof inputObject?.input === "string"
|
||||
? inputObject.input
|
||||
: "";
|
||||
const fileDiffs = patchText ? parseApplyPatchInput(patchText) : [];
|
||||
if (fileDiffs.length > 0) {
|
||||
const additions = fileDiffs.reduce((sum, d) => sum + d.additions, 0);
|
||||
const deletions = fileDiffs.reduce((sum, d) => sum + d.deletions, 0);
|
||||
return {
|
||||
label: `${inProgress ? "Editing" : "Edited"} ${pluralize(fileDiffs.length, "file")}`,
|
||||
diff: { additions, deletions },
|
||||
details: fileDiffs.map(
|
||||
(d) =>
|
||||
`${inProgress ? "Editing" : "Edited"} ${toDisplayPath(d.path)} +${d.additions} -${d.deletions}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: inProgress ? "Applying patch" : "Applied patch",
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (["editor", "edit_file", "edit"].includes(normalized)) {
|
||||
// Current editor schema has no `command`; derive it from the input shape.
|
||||
const command =
|
||||
typeof inputObject?.command === "string" ? inputObject.command : "edit";
|
||||
typeof inputObject?.command === "string"
|
||||
? inputObject.command
|
||||
: inputObject?.insert_line != null
|
||||
? "insert"
|
||||
: typeof inputObject?.old_text === "string"
|
||||
? "str_replace"
|
||||
: typeof inputObject?.new_text === "string"
|
||||
? "create"
|
||||
: "edit";
|
||||
const path =
|
||||
typeof inputObject?.path === "string"
|
||||
? toDisplayPath(inputObject.path)
|
||||
@@ -1107,14 +1289,12 @@ function buildToolSummary(
|
||||
: command === "insert"
|
||||
? "Inserted"
|
||||
: "Edited";
|
||||
// The label already carries all the information; no expandable details.
|
||||
const detail = `${action} ${path}`;
|
||||
if (diff) {
|
||||
return {
|
||||
label: `${detail} +${diff.additions} -${diff.deletions}`,
|
||||
details: [detail],
|
||||
};
|
||||
return { label: detail, diff, details: [] };
|
||||
}
|
||||
return { label: detail, details: [detail] };
|
||||
return { label: detail, details: [] };
|
||||
}
|
||||
|
||||
const query =
|
||||
@@ -1162,13 +1342,17 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
hookEventName === "history_tool_use" ||
|
||||
(Boolean(payload) && payload?.result == null && !payload?.isError);
|
||||
const kind = classifyTool(toolName);
|
||||
const Icon =
|
||||
kind === "exploration"
|
||||
const isFileRead = ["read_files", "file_read", "file-read"].includes(
|
||||
toolName.toLowerCase(),
|
||||
);
|
||||
const Icon = isFileRead
|
||||
? FileIcon
|
||||
: kind === "exploration"
|
||||
? Search
|
||||
: kind === "file-edit"
|
||||
? FileEdit
|
||||
: kind === "bash"
|
||||
? Terminal
|
||||
? SquareTerminalIcon
|
||||
: kind === "spawn"
|
||||
? Bot
|
||||
: FileSearch;
|
||||
@@ -1180,39 +1364,52 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 1 || Boolean(inputPreview || resultPreview);
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start w-full">
|
||||
<div className={cn("w-full rounded-xl text-xs")}>
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
<Button
|
||||
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-3 text-destructive/80" />
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-3" />
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span>{summary.label}</span>
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">
|
||||
-{summary.diff.deletions}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{hasExpandedSections ? (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3" />
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="pl-8 text-muted-foreground">
|
||||
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div className="text-xxs" key={`${message.id}_${detail}`}>
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
@@ -1223,7 +1420,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -1235,7 +1432,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<pre className="max-h-64 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{resultPreview}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Check, FolderOpen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -105,13 +106,14 @@ export function WelcomeScreen({
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden bg-background">
|
||||
<AuroraBackground />
|
||||
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
What would you like to build?
|
||||
What can I do for you?
|
||||
</h1>
|
||||
<p className="mt-2 text-balance text-center text-muted-foreground">
|
||||
Start a conversation to explore, edit, and ship code together.
|
||||
Let's explore, edit, and ship code together!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageFrameProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function PageFrame({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
type PageHeaderProps = {
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
meta?: ReactNode;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
className,
|
||||
description,
|
||||
icon: Icon,
|
||||
meta,
|
||||
title,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
|
||||
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type PageEmptyStateProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandBadgeProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CommandBadge({ children, className }: CommandBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Filter,
|
||||
Folder,
|
||||
GitFork,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
basenamePath,
|
||||
formatCostUsd,
|
||||
formatRelativeTime,
|
||||
parseTimestamp,
|
||||
type SessionThread,
|
||||
type UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import type { SessionHistoryItem } from "@/lib/session-history";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SessionsViewProps = {
|
||||
activeSessionId?: string | null;
|
||||
history: UseSessionHistoryResult;
|
||||
};
|
||||
|
||||
function statusTone(status?: string): string {
|
||||
if (status === "running") return "bg-green-500";
|
||||
if (status === "completed") return "bg-emerald-400";
|
||||
if (status === "failed") return "bg-destructive";
|
||||
if (status === "cancelled") return "bg-yellow-500";
|
||||
return "bg-muted-foreground";
|
||||
}
|
||||
|
||||
function modelLabel(thread: SessionThread): string {
|
||||
if (thread.provider && thread.model) {
|
||||
return `${thread.provider}:${thread.model}`;
|
||||
}
|
||||
return thread.model || thread.provider || "No model";
|
||||
}
|
||||
|
||||
function tokensLabel(thread: SessionThread): string {
|
||||
if (thread.inputTokens == null && thread.outputTokens == null) {
|
||||
return "-";
|
||||
}
|
||||
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
|
||||
}
|
||||
|
||||
function sessionFilterDetails(
|
||||
thread: SessionThread,
|
||||
session?: SessionHistoryItem,
|
||||
): string[] {
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
thread.model ? `model:${thread.model}` : undefined,
|
||||
].filter((detail): detail is string => Boolean(detail));
|
||||
}
|
||||
|
||||
function sortTimestamp(session?: SessionHistoryItem) {
|
||||
const timestamp = parseTimestamp(session?.endedAt || session?.startedAt);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
|
||||
const [sortDirection, setSortDirection] = useState<"newest" | "oldest">(
|
||||
"newest",
|
||||
);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
history.threads.flatMap((thread) =>
|
||||
sessionFilterDetails(thread, history.sessionById.get(thread.id)),
|
||||
),
|
||||
),
|
||||
).sort((a, b) => a.localeCompare(b)),
|
||||
[history.sessionById, history.threads],
|
||||
);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const selected = new Set(sessionFilters);
|
||||
const filtered = history.threads.filter((thread) => {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const details = sessionFilterDetails(thread, session);
|
||||
const matchesFilters =
|
||||
selected.size === 0 || details.some((detail) => selected.has(detail));
|
||||
if (!matchesFilters) {
|
||||
return false;
|
||||
}
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
const searchable = [
|
||||
thread.title,
|
||||
thread.codebase,
|
||||
thread.provider,
|
||||
thread.model,
|
||||
session?.workspaceRoot,
|
||||
session?.cwd,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(normalizedQuery);
|
||||
});
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aTime = sortTimestamp(history.sessionById.get(a.id));
|
||||
const bTime = sortTimestamp(history.sessionById.get(b.id));
|
||||
return sortDirection === "newest" ? bTime - aTime : aTime - bTime;
|
||||
});
|
||||
}, [
|
||||
history.sessionById,
|
||||
history.threads,
|
||||
query,
|
||||
sessionFilters,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const toggleFilter = (detail: string, checked: boolean) => {
|
||||
setSessionFilters((current) => {
|
||||
if (checked) {
|
||||
return current.includes(detail) ? current : [...current, detail];
|
||||
}
|
||||
return current.filter((item) => item !== detail);
|
||||
});
|
||||
};
|
||||
|
||||
const startRename = (thread: SessionThread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
setEditingTitle(thread.title);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
setEditingTitle("");
|
||||
};
|
||||
|
||||
const submitRename = async (thread: SessionThread) => {
|
||||
const renamed = await history.renameThread(thread.id, editingTitle);
|
||||
if (renamed) {
|
||||
cancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteCandidate) {
|
||||
return;
|
||||
}
|
||||
const deleted = await history.deleteThread(deleteCandidate.id);
|
||||
if (deleted) {
|
||||
setDeleteCandidate(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Recent sessions across clients and workspaces.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="relative min-w-44 max-w-72 flex-1">
|
||||
<Search className="-translate-y-1/2 pointer-events-none absolute left-2.5 top-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search sessions"
|
||||
className="h-8 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Sort sessions"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
size="sm"
|
||||
title="Sort sessions"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowUpDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
|
||||
{sortDirection === "newest" ? "Newest first" : "Newest first"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
|
||||
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Filter sessions"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
size="sm"
|
||||
title="Filter sessions"
|
||||
type="button"
|
||||
variant={sessionFilters.length > 0 ? "default" : "outline"}
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-72 w-72">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Filter sessions</DropdownMenuLabel>
|
||||
{sessionFilters.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setSessionFilters([])}>
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{filterOptions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
No filters available
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filterOptions.map((detail) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sessionFilters.includes(detail)}
|
||||
key={detail}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleFilter(detail, checked === true)
|
||||
}
|
||||
>
|
||||
<span className="truncate" title={detail}>
|
||||
{detail}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
||||
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Session</span>
|
||||
<span>Workspace</span>
|
||||
<span>Model</span>
|
||||
<span>Tokens</span>
|
||||
<span>Cost</span>
|
||||
<span>Updated</span>
|
||||
<span />
|
||||
</div>
|
||||
<div>
|
||||
{history.isLoadingHistory && history.threads.length === 0 ? (
|
||||
<div className="flex items-center gap-2 border-t px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading session history...
|
||||
</div>
|
||||
) : null}
|
||||
{!history.isLoadingHistory && filteredThreads.length === 0 ? (
|
||||
<div className="border-t px-4 py-8 text-sm text-muted-foreground">
|
||||
{history.threads.length === 0
|
||||
? "No sessions yet."
|
||||
: "No sessions match the current filters."}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredThreads.map((thread) => {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const isEditing = editingSessionId === thread.id;
|
||||
const isPending = history.pendingAction?.sessionId === thread.id;
|
||||
const pendingKind = isPending
|
||||
? history.pendingAction?.action
|
||||
: null;
|
||||
const workspace = session?.workspaceRoot || session?.cwd || "";
|
||||
const updated = formatRelativeTime(
|
||||
session?.endedAt || session?.startedAt,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
|
||||
activeSessionId === thread.id
|
||||
? "bg-accent/50"
|
||||
: "hover:bg-accent/30",
|
||||
)}
|
||||
key={thread.id}
|
||||
>
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="col-span-6 grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitRename(thread);
|
||||
}}
|
||||
>
|
||||
<div className="col-span-2 flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
aria-label={`Rename ${thread.title}`}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
disabled={pendingKind === "rename"}
|
||||
onChange={(event) =>
|
||||
setEditingTitle(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
value={editingTitle}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Save title"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={
|
||||
pendingKind === "rename" || !editingTitle.trim()
|
||||
}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{pendingKind === "rename" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Cancel rename"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={pendingKind === "rename"}
|
||||
onClick={cancelRename}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{tokensLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatCostUsd(thread.totalCostUsd) ?? "-"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{updated || thread.time}
|
||||
</span>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
|
||||
disabled={Boolean(pendingKind)}
|
||||
onClick={() => {
|
||||
if (pendingKind) {
|
||||
return;
|
||||
}
|
||||
// Don't open the session when the user is selecting text.
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
history.openThread(thread.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3 font-semibold">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
statusTone(thread.status),
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={workspace}>
|
||||
{workspace ? basenamePath(workspace) : "No workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{tokensLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatCostUsd(thread.totalCostUsd) ?? "-"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{updated || thread.time}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
{pendingKind ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{history.mayHaveMoreSessions ? (
|
||||
<div className="border-t px-4 py-3">
|
||||
<Button
|
||||
className="h-8 rounded-md px-3 text-xs"
|
||||
disabled={history.isLoadingMore}
|
||||
onClick={() =>
|
||||
void history.loadMoreSessions(history.threads.length + 100)
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{history.isLoadingMore ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteCandidate !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && history.pendingAction?.action !== "delete") {
|
||||
setDeleteCandidate(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes "{deleteCandidate?.title ?? "this session"}" from
|
||||
local history.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={history.pendingAction?.action === "delete"}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={
|
||||
!deleteCandidate || history.pendingAction?.action === "delete"
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void confirmDelete();
|
||||
}}
|
||||
>
|
||||
{history.pendingAction?.action === "delete" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
"use client";
|
||||
|
||||
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CommandBadge,
|
||||
PageEmptyState,
|
||||
PageFrame,
|
||||
PageHeader,
|
||||
} from "../page-layout";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type ConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type ActiveConnector = {
|
||||
id: string;
|
||||
type: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
startedAt?: string;
|
||||
applicationId?: string;
|
||||
botUsername?: string;
|
||||
userName?: string;
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
type ConnectorChannelsResponse = {
|
||||
available: ConnectorChannel[];
|
||||
active: ActiveConnector[];
|
||||
};
|
||||
|
||||
type ConnectorFormState = {
|
||||
channelId: string;
|
||||
values: Record<string, string>;
|
||||
securityEnabled: boolean;
|
||||
securityValues: Record<string, string>;
|
||||
};
|
||||
|
||||
function connectorName(
|
||||
connector: ActiveConnector,
|
||||
channels: ConnectorChannel[],
|
||||
): string {
|
||||
return (
|
||||
channels.find((channel) => channel.id === connector.type)?.name ??
|
||||
connector.type
|
||||
);
|
||||
}
|
||||
|
||||
function connectorIdentity(connector: ActiveConnector): string {
|
||||
if (connector.botUsername) {
|
||||
return `@${connector.botUsername}`;
|
||||
}
|
||||
if (connector.userName) {
|
||||
return connector.userName;
|
||||
}
|
||||
if (connector.applicationId) {
|
||||
return connector.applicationId;
|
||||
}
|
||||
return `pid ${connector.pid}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function isSecretField(
|
||||
field: ConnectorField | ConnectorSecurityField,
|
||||
): boolean {
|
||||
const label = field.label.toLowerCase();
|
||||
const key =
|
||||
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
|
||||
return (
|
||||
label.includes("token") ||
|
||||
label.includes("secret") ||
|
||||
label.includes("key") ||
|
||||
key.includes("token") ||
|
||||
key.includes("secret") ||
|
||||
key.includes("key")
|
||||
);
|
||||
}
|
||||
|
||||
function isMultilineField(field: ConnectorField): boolean {
|
||||
const label = field.label.toLowerCase();
|
||||
return label.includes("json") || field.flag.includes("credentials");
|
||||
}
|
||||
|
||||
function shouldIncludeField(
|
||||
field: ConnectorField,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialValuesForChannel(
|
||||
channel?: ConnectorChannel,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of channel?.fields ?? []) {
|
||||
if (field.initialValue) {
|
||||
values[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
|
||||
const channel = channels[0];
|
||||
return {
|
||||
channelId: channel?.id ?? "",
|
||||
values: initialValuesForChannel(channel),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function ChannelsContent() {
|
||||
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
|
||||
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [busyChannel, setBusyChannel] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<ConnectorFormState>({
|
||||
channelId: "",
|
||||
values: {},
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedChannel = useMemo(
|
||||
() => channels.find((channel) => channel.id === formState.channelId),
|
||||
[channels, formState.channelId],
|
||||
);
|
||||
const visibleFields = useMemo(() => {
|
||||
const values = {
|
||||
...initialValuesForChannel(selectedChannel),
|
||||
...formState.values,
|
||||
};
|
||||
return (selectedChannel?.fields ?? []).filter((field) =>
|
||||
shouldIncludeField(field, values),
|
||||
);
|
||||
}, [selectedChannel, formState.values]);
|
||||
|
||||
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
|
||||
setChannels(response.available);
|
||||
setActiveConnectors(response.active);
|
||||
setFormState((prev) =>
|
||||
prev.channelId ? prev : createFormState(response.available),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshChannels = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"list_connector_channels",
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshChannels();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshChannels]);
|
||||
|
||||
const openAddDialog = () => {
|
||||
setFormState(createFormState(channels));
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const updateFieldValue = (flag: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
values: { ...prev.values, [flag]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSecurityFieldValue = (key: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
securityValues: { ...prev.securityValues, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const startConnector = async () => {
|
||||
if (!selectedChannel) {
|
||||
setFormError("Choose a channel");
|
||||
return;
|
||||
}
|
||||
for (const field of selectedChannel.fields) {
|
||||
if (!visibleFields.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field.required && !formState.values[field.flag]?.trim()) {
|
||||
setFormError(`${field.label} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (formState.securityEnabled && selectedChannel.security) {
|
||||
for (const field of selectedChannel.security.fields) {
|
||||
if (!formState.securityValues[field.key]?.trim()) {
|
||||
setFormError(field.requiredMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setBusyChannel(selectedChannel.id);
|
||||
setFormError(null);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"start_connector_channel",
|
||||
{
|
||||
channel: selectedChannel.id,
|
||||
values: formState.values,
|
||||
security: {
|
||||
enabled: formState.securityEnabled,
|
||||
values: formState.securityValues,
|
||||
},
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setFormError(message);
|
||||
} finally {
|
||||
setBusyChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stopConnector = async (connector: ActiveConnector) => {
|
||||
setBusyChannel(connector.type);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"stop_connector_channel",
|
||||
{ channel: connector.type },
|
||||
);
|
||||
applyResponse(response);
|
||||
setRemoveTarget(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline.`}
|
||||
title="Channels"
|
||||
meta={<CommandBadge>cline connect</CommandBadge>}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshChannels()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={channels.length === 0}
|
||||
onClick={openAddDialog}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<PageEmptyState>Loading channels...</PageEmptyState>
|
||||
) : activeConnectors.length === 0 ? (
|
||||
<PageEmptyState>No channels connected.</PageEmptyState>
|
||||
) : (
|
||||
<section className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid gap-2 p-2.5">
|
||||
{activeConnectors.map((connector) => (
|
||||
<div
|
||||
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
key={connector.id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
|
||||
<p className="truncate text-[13px] font-semibold leading-tight">
|
||||
{connectorName(connector, channels)}
|
||||
</p>
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{connectorIdentity(connector)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
pid={connector.pid}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.hubUrl}
|
||||
>
|
||||
{connector.hubUrl}
|
||||
</span>
|
||||
{connector.baseUrl ? (
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.baseUrl}
|
||||
>
|
||||
{connector.baseUrl}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busyChannel === connector.type}
|
||||
onClick={() => setRemoveTarget(connector)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove...
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a connector channel for Cline.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Channel</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setFormState({
|
||||
channelId: value,
|
||||
values: initialValuesForChannel(
|
||||
channels.find((channel) => channel.id === value),
|
||||
),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
}}
|
||||
value={formState.channelId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{visibleFields.map((field) => (
|
||||
<div className="grid gap-2" key={field.flag}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<span className="text-destructive"> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{field.options ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
updateFieldValue(field.flag, value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
formState.values[field.flag] ?? field.initialValue ?? ""
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isMultilineField(field) ? (
|
||||
<Textarea
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
rows={5}
|
||||
value={formState.values[field.flag] ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
type={isSecretField(field) ? "password" : "text"}
|
||||
value={formState.values[field.flag] ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedChannel?.security ? (
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-sm">Restrict access</Label>
|
||||
<Switch
|
||||
checked={formState.securityEnabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
securityEnabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{formState.securityEnabled
|
||||
? selectedChannel.security.fields.map((field) => (
|
||||
<div className="grid gap-2" key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateSecurityFieldValue(
|
||||
field.key,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
type={isSecretField(field) ? "password" : "text"}
|
||||
value={formState.securityValues[field.key] ?? ""}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={busyChannel !== null}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busyChannel !== null || !selectedChannel}
|
||||
onClick={() => void startConnector()}
|
||||
type="button"
|
||||
>
|
||||
{busyChannel ? "Starting..." : "Add Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={removeTarget !== null}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Channel</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Confirm that you want to stop the active{" "}
|
||||
{removeTarget ? connectorName(removeTarget, channels) : "channel"}{" "}
|
||||
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busyChannel !== null}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busyChannel !== null || !removeTarget}
|
||||
onClick={() => {
|
||||
if (removeTarget) {
|
||||
void stopConnector(removeTarget);
|
||||
}
|
||||
}}
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
+1063
-592
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
@@ -203,7 +203,10 @@ export function McpServersContent() {
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshServers();
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshServers();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshServers]);
|
||||
|
||||
const toggleServer = async (server: McpServer, disabled: boolean) => {
|
||||
@@ -402,18 +405,24 @@ export function McpServersContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 className="truncate text-lg font-semibold text-foreground">
|
||||
MCP Servers
|
||||
</h2>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={
|
||||
hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."
|
||||
}
|
||||
title="MCP Servers"
|
||||
meta={
|
||||
<>
|
||||
<CommandBadge>cline config mcp</CommandBadge>
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
From settings file
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -423,151 +432,138 @@ export function McpServersContent() {
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
{hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."}
|
||||
</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Command:
|
||||
</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers &&
|
||||
Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Headers:
|
||||
</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Command:</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers && Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Headers:</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -852,6 +848,6 @@ export function McpServersContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+292
-116
@@ -2,18 +2,21 @@
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Search,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -82,94 +85,146 @@ function coerceFieldValue(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function assignSettingsPath(
|
||||
target: Record<string, unknown>,
|
||||
path: string,
|
||||
value: ProviderConfigFieldPrimitive,
|
||||
) {
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) return;
|
||||
let cursor = target;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = cursor[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Record<string, unknown>;
|
||||
}
|
||||
const last = segments.at(-1);
|
||||
if (last) {
|
||||
cursor[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function toSettingsPatch(
|
||||
values: Record<string, ProviderConfigFieldPrimitive>,
|
||||
): Record<string, unknown> {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
assignSettingsPath(settings, path, value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function ProviderListContent({
|
||||
providers,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
selectedProviderId,
|
||||
variant = "page",
|
||||
}: {
|
||||
providers: Provider[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfigure: (id: string) => void;
|
||||
onAddProvider: () => void;
|
||||
selectedProviderId?: string | null;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
const [providerSearch, setProviderSearch] = useState("");
|
||||
const enabledProviderCount = providers.filter(
|
||||
(provider) => provider.enabled,
|
||||
).length;
|
||||
const providerSearchQuery = providerSearch.trim().toLowerCase();
|
||||
const filteredProviders = providerSearchQuery
|
||||
? providers.filter((provider) =>
|
||||
provider.name.toLowerCase().includes(providerSearchQuery),
|
||||
)
|
||||
: providers;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Model Providers
|
||||
</h2>
|
||||
<Button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
|
||||
onClick={onAddProvider}
|
||||
variant="ghost"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Model Providers
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
{providers.length} available · {enabledProviderCount}{" "}
|
||||
enabled
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
|
||||
<Button
|
||||
aria-label="Search providers"
|
||||
className="size-8 rounded-md"
|
||||
onClick={() => setProviderSearchOpen((open) => !open)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant={providerSearchOpen ? "default" : "secondary"}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
|
||||
onClick={onAddProvider}
|
||||
type="button"
|
||||
>
|
||||
<PlusCircle className="size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
|
||||
{providers.map((prov) => (
|
||||
{providerSearchOpen ? (
|
||||
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
|
||||
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search model providers"
|
||||
autoFocus
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm"
|
||||
onChange={(event) => setProviderSearch(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
value={providerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
|
||||
No providers match "{providerSearch.trim()}".
|
||||
</div>
|
||||
) : null}
|
||||
{filteredProviders.map((prov) => (
|
||||
<div
|
||||
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
|
||||
selectedProviderId === prov.id && "bg-accent/45",
|
||||
)}
|
||||
key={prov.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
|
||||
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -187,6 +242,7 @@ export function ProviderDetailContent({
|
||||
modelsError,
|
||||
onOAuthLogin,
|
||||
oauthLoginPending = false,
|
||||
variant = "page",
|
||||
}: {
|
||||
provider: Provider;
|
||||
onBack: () => void;
|
||||
@@ -196,18 +252,49 @@ export function ProviderDetailContent({
|
||||
modelsError?: string | null;
|
||||
onOAuthLogin?: () => void;
|
||||
oauthLoginPending?: boolean;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
Record<string, ProviderConfigFieldPrimitive>
|
||||
>(() => getInitialConfigValues(provider));
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfigValues(getInitialConfigValues(provider));
|
||||
}, [provider]);
|
||||
const [modelSearchState, setModelSearchState] = useState<{
|
||||
providerId: string;
|
||||
value: string;
|
||||
} | null>(null);
|
||||
const [copiedModelState, setCopiedModelState] = useState<{
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
} | null>(null);
|
||||
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const configFields = provider.configFields ?? [];
|
||||
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
|
||||
const modelList = provider.modelList ?? [];
|
||||
const modelSearch =
|
||||
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
|
||||
const copiedModelId =
|
||||
copiedModelState?.providerId === provider.id
|
||||
? copiedModelState.modelId
|
||||
: null;
|
||||
const modelSearchQuery = modelSearch.trim().toLowerCase();
|
||||
const filteredModelList = modelSearchQuery
|
||||
? modelList.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(modelSearchQuery) ||
|
||||
model.id.toLowerCase().includes(modelSearchQuery),
|
||||
)
|
||||
: modelList;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const commitField = (
|
||||
field: ProviderConfigField,
|
||||
@@ -232,46 +319,83 @@ export function ProviderDetailContent({
|
||||
onUpdate(updates);
|
||||
};
|
||||
|
||||
const copyModelId = (modelId: string) => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(modelId).then(() => {
|
||||
setCopiedModelState({ modelId, providerId: provider.id });
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
copiedModelTimeoutRef.current = window.setTimeout(
|
||||
() => setCopiedModelState(null),
|
||||
1600,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
{/* Back + title */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<Button
|
||||
aria-label="Back to providers"
|
||||
aria-label={
|
||||
isPanel ? "Close provider details" : "Back to providers"
|
||||
}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{isPanel ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{configFields.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
<section
|
||||
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{configFields.map((field) => {
|
||||
const value = localConfigValues[field.path];
|
||||
const valueText = fieldValueToString(value);
|
||||
const isSecret = field.type === "password" || field.secret;
|
||||
const isShown = shownSecrets[field.path] ?? false;
|
||||
return (
|
||||
<div key={field.path}>
|
||||
<header className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
<div
|
||||
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
|
||||
key={field.path}
|
||||
>
|
||||
<header>
|
||||
<h3 className="text-[17px] font-semibold text-foreground">
|
||||
{field.label}
|
||||
</h3>
|
||||
{field.description ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{field.type === "boolean" ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
@@ -284,7 +408,7 @@ export function ProviderDetailContent({
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
commitField(field, event.target.value)
|
||||
}
|
||||
@@ -301,12 +425,12 @@ export function ProviderDetailContent({
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
|
||||
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
|
||||
{field.type === "url" ? (
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Input
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onBlur={() => commitField(field, valueText)}
|
||||
onChange={(event) =>
|
||||
setLocalConfigValues((current) => ({
|
||||
@@ -392,10 +516,18 @@ export function ProviderDetailContent({
|
||||
) : null}
|
||||
|
||||
{/* Models section */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Models</h3>
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isPanel ? "max-w-none" : "max-w-[46rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
|
||||
<h2 className="text-[17px] font-medium text-muted-foreground">
|
||||
Models
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
aria-label="Refresh models"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
@@ -414,39 +546,83 @@ export function ProviderDetailContent({
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-destructive">{modelsError}</p>
|
||||
</div>
|
||||
) : provider.modelList && provider.modelList.length > 0 ? (
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
|
||||
{provider.modelList.map((model) => (
|
||||
<div
|
||||
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
{/* Model name */}
|
||||
<span className="flex-1 text-sm text-foreground font-mono">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{model.name}
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{model.supportsVision && (
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
aria-label={`Favorite ${model.name}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
) : modelList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search models"
|
||||
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
onChange={(event) =>
|
||||
setModelSearchState({
|
||||
providerId: provider.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Search models by name or ID"
|
||||
spellCheck={false}
|
||||
value={modelSearch}
|
||||
/>
|
||||
</div>
|
||||
{filteredModelList.length > 0 ? (
|
||||
<div className="max-h-125 overflow-y-scroll border-t">
|
||||
{filteredModelList.map((model) => (
|
||||
<div
|
||||
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 font-mono">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<div title="File Support">
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{model.supportsVision && (
|
||||
<div title="Image Support">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={`Copy model ID ${model.id}`}
|
||||
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => copyModelId(model.id)}
|
||||
title="Copy model ID"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate">{model.id}</span>
|
||||
<Copy className="size-3 shrink-0" />
|
||||
{copiedModelId === model.id ? (
|
||||
<span className="shrink-0 text-foreground">
|
||||
Copied
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
aria-label={`Favorite ${model.name}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No models match "{modelSearch.trim()}".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import type { ProviderConfigFieldPrimitive } from "@/lib/provider-schema";
|
||||
|
||||
function assignSettingsPath(
|
||||
target: Record<string, unknown>,
|
||||
path: string,
|
||||
value: ProviderConfigFieldPrimitive,
|
||||
) {
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) return;
|
||||
let cursor = target;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = cursor[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Record<string, unknown>;
|
||||
}
|
||||
const last = segments.at(-1);
|
||||
if (last) {
|
||||
cursor[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function toSettingsPatch(
|
||||
values: Record<string, ProviderConfigFieldPrimitive>,
|
||||
): Record<string, unknown> {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
assignSettingsPath(settings, path, value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
Provider,
|
||||
@@ -11,20 +10,25 @@ import type {
|
||||
ProviderModelsResponse,
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubTheme,
|
||||
readStoredHubTheme,
|
||||
readSystemHubTheme,
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { primeExtensionsListsCache, RulesView } from "./extensions-view";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { CustomizationSectionView, RulesView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
toSettingsPatch,
|
||||
} from "./provider-list-view";
|
||||
import {
|
||||
primeRoutineOverviewCache,
|
||||
RoutineSchedulesContent,
|
||||
} from "./routine-view";
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Settings nav categories
|
||||
@@ -33,14 +37,15 @@ import {
|
||||
const navCategories = [
|
||||
"General",
|
||||
"Providers",
|
||||
"Extensions",
|
||||
"MCP",
|
||||
"Routine",
|
||||
"Features",
|
||||
"Marketplace",
|
||||
"Extensions",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
type NavCategory = (typeof navCategories)[number];
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -53,9 +58,18 @@ let providerCatalogCache: {
|
||||
// Component
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
const [activeNav, setActiveNav] = useState<NavCategory>("Providers");
|
||||
const [providersExpanded, setProvidersExpanded] = useState(true);
|
||||
export function SettingsView({
|
||||
chrome = "full",
|
||||
initialSection = "General",
|
||||
onClose,
|
||||
onNavigateSection,
|
||||
}: {
|
||||
chrome?: "full" | "content";
|
||||
initialSection?: SettingsSection;
|
||||
onClose: () => void;
|
||||
onNavigateSection?: (section: SettingsSection) => void;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
() => providerCatalogCache?.providers ?? [],
|
||||
);
|
||||
@@ -125,14 +139,14 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviderCatalog();
|
||||
void primeRoutineOverviewCache().catch(() => {
|
||||
// Keep settings responsive even if routine prefetch fails.
|
||||
});
|
||||
void primeExtensionsListsCache().catch(() => {
|
||||
// Keep settings responsive even if extension prefetch fails.
|
||||
});
|
||||
}, [loadProviderCatalog]);
|
||||
if (activeNav !== "Providers") {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderCatalog();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [activeNav, loadProviderCatalog]);
|
||||
|
||||
const persistProviderSettings = useCallback(
|
||||
async (
|
||||
@@ -237,13 +251,12 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
[setProvidersWithCache],
|
||||
);
|
||||
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
const selectedProvider = selectedProviderId
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -276,6 +289,7 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const openProviderDetail = (id: string) => {
|
||||
setActiveNav("Providers");
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(id);
|
||||
};
|
||||
|
||||
@@ -289,10 +303,14 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
if (!selected || (selected.modelList?.length ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
void loadProviderModels(selectedProviderId);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderModels(selectedProviderId);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderModels, providers, selectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
@@ -319,10 +337,102 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
);
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const selectSection = (section: SettingsSection) => {
|
||||
setActiveNav(section);
|
||||
onNavigateSection?.(section);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : selectedProvider ? (
|
||||
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
/>
|
||||
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
usesOAuth(selectedProvider)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
|
||||
provider={selectedProvider}
|
||||
variant="panel"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Providers" ? (
|
||||
providerContent
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
|
||||
) : activeNav === "Extensions" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
<GeneralSettingsContent />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (chrome === "content") {
|
||||
return (
|
||||
<div className="h-full overflow-hidden bg-background">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header bar */}
|
||||
@@ -344,144 +454,68 @@ export function SettingsView({ onClose }: { onClose: () => void }) {
|
||||
<nav className="w-56 shrink-0 border-r border-border">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{navCategories.map((cat) => {
|
||||
if (cat === "Providers") {
|
||||
return (
|
||||
<div key={cat}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
|
||||
activeNav === "Providers"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveNav("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
setProvidersExpanded((p) => !p);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<span>Providers</span>
|
||||
{providersExpanded ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
{providersExpanded && (
|
||||
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-border pl-2">
|
||||
{enabledProviders.map((prov) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
selectedProviderId === prov.id
|
||||
? "bg-accent/80 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/30",
|
||||
)}
|
||||
disabled={oauthSigningProviderId === prov.id}
|
||||
key={prov.id}
|
||||
onClick={() => openProviderDetail(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate">{prov.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat && !selectedProviderId
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
setActiveNav(cat);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{navCategories.map((cat) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
selectSection(cat);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeNav === "Providers" && selectedProvider ? (
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={
|
||||
modelsLoadingByProvider[selectedProvider.id] ?? false
|
||||
}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) =>
|
||||
updateProvider(selectedProvider.id, updates)
|
||||
}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
) : activeNav === "Providers" ? (
|
||||
addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading providers...
|
||||
</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
)
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Routine" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Extensions" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSettingsContent() {
|
||||
const [theme, setTheme] = useState<HubTheme>(() => {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Manage desktop preferences for this browser and CLI environment."
|
||||
title="Settings"
|
||||
/>
|
||||
<section className="max-w-[86rem]">
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Dark mode
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Keep the desktop interface in dark mode on this browser.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Dark mode"
|
||||
checked={theme === "dark"}
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
mode: "act",
|
||||
systemPrompt: undefined,
|
||||
maxIterations: undefined,
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
enableTools: true,
|
||||
enableSpawn: undefined,
|
||||
enableTeams: undefined,
|
||||
|
||||
@@ -116,10 +116,13 @@ export function normalizeRuntimeConfig(
|
||||
): ChatSessionConfig {
|
||||
const normalizedWorkspaceRoot = config.workspaceRoot.trim();
|
||||
const normalizedCwd = (config.cwd?.trim() || normalizedWorkspaceRoot).trim();
|
||||
const thinking = config.reasoningEffort ? true : config.thinking;
|
||||
return {
|
||||
...config,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
cwd: normalizedCwd || normalizedWorkspaceRoot,
|
||||
thinking,
|
||||
reasoningEffort: thinking === false ? undefined : config.reasoningEffort,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
};
|
||||
|
||||
@@ -101,7 +101,11 @@ export type ChatWsChunkEvent = {
|
||||
event: AgentChunkEvent;
|
||||
};
|
||||
|
||||
export type ChatTransportState = "connecting" | "reconnecting" | "connected";
|
||||
export type ChatTransportState =
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
|
||||
export type CoreLogChunk = {
|
||||
level?: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildSessionDiffState,
|
||||
type SessionHookEvent,
|
||||
EMPTY_DIFF_SUMMARY,
|
||||
type SessionDiffSummary,
|
||||
type SessionFileDiff,
|
||||
@@ -241,6 +242,9 @@ export function useChatSession() {
|
||||
const hydrationRequestIdRef = useRef(0);
|
||||
const [chatTransportState, setChatTransportState] =
|
||||
useState<ChatTransportState>(desktopClient.getTransportState());
|
||||
const [chatTransportError, setChatTransportError] = useState<string | null>(
|
||||
desktopClient.getTransportError(),
|
||||
);
|
||||
// ---- Ref syncs ----
|
||||
|
||||
useEffect(() => {
|
||||
@@ -493,6 +497,50 @@ export function useChatSession() {
|
||||
void refreshPromptsInQueue(sessionId);
|
||||
}, [refreshPromptsInQueue, refreshSessionDiffSummary, sessionId]);
|
||||
|
||||
// Fallback for sessions with no tool events in the hook log (e.g. sessions
|
||||
// recorded before tool_call/tool_result hook logging existed): rebuild the
|
||||
// diff state from the tool messages themselves.
|
||||
useEffect(() => {
|
||||
if (!sessionId || fileDiffs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const events: SessionHookEvent[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.sessionId !== sessionId || message.role !== "tool") {
|
||||
continue;
|
||||
}
|
||||
let payload: {
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
} | null = null;
|
||||
try {
|
||||
payload = JSON.parse(message.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!payload?.toolName || payload.result == null || payload.isError) {
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
hookName: "tool_result",
|
||||
toolName: payload.toolName,
|
||||
toolInput: payload.input,
|
||||
toolOutput: payload.result,
|
||||
});
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
const diffState = buildSessionDiffState(events);
|
||||
if (diffState.fileDiffs.length === 0) {
|
||||
return;
|
||||
}
|
||||
setFileDiffs(diffState.fileDiffs);
|
||||
setDiffSummary(diffState.summary);
|
||||
}, [sessionId, messages, fileDiffs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeSessionId = sessionId;
|
||||
if (!activeSessionId) {
|
||||
@@ -790,7 +838,10 @@ export function useChatSession() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeTransport = desktopClient.subscribeTransportState(
|
||||
setChatTransportState,
|
||||
(state) => {
|
||||
setChatTransportState(state);
|
||||
setChatTransportError(desktopClient.getTransportError());
|
||||
},
|
||||
);
|
||||
const unsubscribeEvents = desktopClient.subscribe(
|
||||
"chat_event",
|
||||
@@ -1669,6 +1720,7 @@ export function useChatSession() {
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState,
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user