mirror of
https://github.com/cline/cline.git
synced 2026-09-05 04:19:50 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2faf38d72 | |||
| 4dab17769c | |||
| 396032cd3b | |||
| f33ab3a872 | |||
| 2ca8364ffc | |||
| 2ef81be703 | |||
| 359445ae0c | |||
| d9e2e9c76b | |||
| d859a86a6f | |||
| 0b7b9c1b3d | |||
| 557d725690 | |||
| 7274d8badc | |||
| d1837366c0 | |||
| c380daf4a3 | |||
| c564045d81 |
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -1,5 +1,18 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
## 3.0.45
|
||||
|
||||
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
|
||||
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
|
||||
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
|
||||
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
|
||||
- Hub status output now includes version numbers
|
||||
- Updated the bundled model catalog (from SDK v0.0.65)
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.44",
|
||||
"version": "3.0.46",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
|
||||
const {
|
||||
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.63",
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
cliVersion,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
|
||||
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -134,6 +135,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClineAccountCreditsErrorMessage", () => {
|
||||
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the plain human-readable Cline API message", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("Not enough credits available"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the legacy insufficient balance phrasing", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Insufficient balance. Your Cline credits balance is $0.00.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Your credit balance is too low to access the Anthropic API.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
// The Cline API's 402 response carries `code: "insufficient_credits"` and
|
||||
// the message "Not enough credits available". Depending on how much of the
|
||||
// payload survives error extraction, the CLI may see the raw JSON blob or
|
||||
// just the human-readable message, so match both. The
|
||||
// "insufficient balance" pair is an older backend phrasing kept for safety.
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
normalized.includes("insufficient_credits") ||
|
||||
normalized.includes("not enough credits") ||
|
||||
(normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"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",
|
||||
@@ -16,7 +19,10 @@
|
||||
"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",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Message as AgentMessage,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
@@ -20,15 +35,7 @@ import {
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
@@ -86,11 +93,9 @@ type AskQuestionRequestItem = {
|
||||
};
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
const STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function ChatMessagesImpl({
|
||||
sessionId: _sessionId,
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState = "connecting",
|
||||
isSessionSwitching = false,
|
||||
@@ -105,9 +110,6 @@ function ChatMessagesImpl({
|
||||
onRestoreCheckpoint,
|
||||
onForkSession,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
@@ -115,7 +117,6 @@ function ChatMessagesImpl({
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [toolApprovalActions, setToolApprovalActions] = useState<
|
||||
Record<string, "approving" | "rejecting">
|
||||
>({});
|
||||
@@ -140,23 +141,6 @@ function ChatMessagesImpl({
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
|
||||
const getViewport = useCallback(() => {
|
||||
return scrollAreaRef.current;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "smooth") => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||||
setShowScrollToBottom((prev) => (prev ? false : prev));
|
||||
},
|
||||
[getViewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionSwitching) {
|
||||
setShowSwitchTransition((prev) => (prev ? false : prev));
|
||||
@@ -170,50 +154,6 @@ function ChatMessagesImpl({
|
||||
};
|
||||
}, [isSessionSwitching]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScrollToBottomVisibility = () => {
|
||||
const distanceFromBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
shouldStickToBottomRef.current =
|
||||
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
|
||||
const shouldShow =
|
||||
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
|
||||
setShowScrollToBottom((prev) =>
|
||||
prev === shouldShow ? prev : shouldShow,
|
||||
);
|
||||
};
|
||||
|
||||
updateScrollToBottomVisibility();
|
||||
viewport.addEventListener("scroll", updateScrollToBottomVisibility);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollToBottomVisibility);
|
||||
};
|
||||
}, [getViewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollToBottom("auto");
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const content = scrollContentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) scrollToBottom("auto");
|
||||
});
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRequestIds = new Set(
|
||||
pendingToolApprovals.map((item) => item.requestId),
|
||||
@@ -377,17 +317,19 @@ function ChatMessagesImpl({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
<Conversation
|
||||
className="h-full min-h-0 min-w-0"
|
||||
key={sessionId ?? "new-chat"}
|
||||
>
|
||||
<ConversationViewport
|
||||
aria-label="Agent conversation"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<div
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
|
||||
showIdleDetails ? "p-0" : "px-6 py-6",
|
||||
)}
|
||||
ref={scrollContentRef}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
@@ -497,21 +439,10 @@ function ChatMessagesImpl({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Button
|
||||
className="absolute bottom-4 right-4 z-20 size-9 rounded-full shadow-sm"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -746,8 +677,6 @@ function MessageBubble({
|
||||
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} />;
|
||||
@@ -760,159 +689,102 @@ function MessageBubble({
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[85%] flex-col items-end gap-1 md:max-w-[50%]",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
<AgentMessage from={message.role}>
|
||||
<MessageContent className="space-y-2 wrap-break-word">
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</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,
|
||||
)}
|
||||
</MessageContent>
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
<>
|
||||
<MessageActions visible={keepUserActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={wasCopied ? "Copied user message" : "Copy user message"}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
>
|
||||
{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">
|
||||
{restoreError}
|
||||
</div>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
{checkpoint ? (
|
||||
<MessageAction
|
||||
disabled={restoreDisabled || restorePending}
|
||||
label="Restore checkpoint"
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
title="Restore checkpoint"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shouldRenderAssistantActions ? (
|
||||
<MessageActions visible={keepAssistantActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
disabled={forkPending}
|
||||
label="Fork session"
|
||||
onClick={onForkSession}
|
||||
title="Fork session - copy full message history into a new session"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
) : null}
|
||||
</AgentMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -925,48 +797,18 @@ function ReasoningBlock({
|
||||
redacted: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const displayContent = content || (redacted ? "[redacted]" : "");
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
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 has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon aria-hidden="true" className="size-4" />
|
||||
<span>{streaming ? "Thinking" : "Thought process"}</span>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
{streaming ? "In progress" : "Complete"}
|
||||
</span>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Reasoning isStreaming={streaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1347,8 +1189,6 @@ function buildToolSummaryFromMeta(
|
||||
}
|
||||
|
||||
function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const payload = parseToolPayload(message.content);
|
||||
const toolName = message.meta?.toolName || payload?.toolName || "tool";
|
||||
const hookEventName = message.meta?.hookEventName;
|
||||
@@ -1380,94 +1220,50 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
const summaryContent = (
|
||||
<>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
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 has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{summaryContent}
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex max-w-full items-center justify-start gap-2 py-1 text-left text-sm font-medium text-primary">
|
||||
{summaryContent}
|
||||
</div>
|
||||
)}
|
||||
{expanded ? (
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1">
|
||||
<span className="text-destructive">{resultPreview}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{resultPreview}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<ToolActivity expandable={hasExpandedSections}>
|
||||
<ToolActivityTrigger
|
||||
additions={summary.diff?.additions}
|
||||
deletions={summary.diff?.deletions}
|
||||
icon={
|
||||
payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={summary.label}
|
||||
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
{details.length > 0 ? (
|
||||
<ToolActivityDetails>
|
||||
{details.map((detail) => (
|
||||
<div key={`${message.id}_${detail}`}>{detail}</div>
|
||||
))}
|
||||
</ToolActivityDetails>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<ToolActivityCode className="text-sm">
|
||||
{inputPreview}
|
||||
</ToolActivityCode>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1 text-destructive">{resultPreview}</div>
|
||||
) : (
|
||||
<ToolActivityCode className="max-h-64 text-sm">
|
||||
{resultPreview}
|
||||
</ToolActivityCode>
|
||||
)
|
||||
) : null}
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", request.defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
// and reused when compatible, or skipped when not. No session rebuild
|
||||
// is needed: the run_commands tool re-reads the profile each time a
|
||||
// model request is built, so the description and execution both pick
|
||||
// up the new shell at the next request boundary.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(request.defaultTerminalProfile)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,10 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
// and reused when compatible, or skipped when not. No session rebuild
|
||||
// is needed: the run_commands tool re-reads the profile each time a
|
||||
// model request is built, so the description and execution both pick
|
||||
// up the new shell at the next request boundary.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(defaultTerminalProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,10 +262,15 @@ export class VscodeTerminalManager {
|
||||
return mergePromise(process, promise)
|
||||
}
|
||||
|
||||
async getOrCreateTerminal(cwd: string): Promise<ITerminalInfo> {
|
||||
/**
|
||||
* @param profileId Terminal profile to create/match the terminal with.
|
||||
* Defaults to the current setting; callers that captured the profile
|
||||
* earlier (e.g. when the model request was built) pass it here so a
|
||||
* settings change does not switch shells under an in-flight tool call.
|
||||
*/
|
||||
async getOrCreateTerminal(cwd: string, profileId: string = this.defaultTerminalProfile): Promise<ITerminalInfo> {
|
||||
const terminals = TerminalRegistry.getAllTerminals()
|
||||
const expectedShellPath =
|
||||
this.defaultTerminalProfile !== "default" ? getShellForProfile(this.defaultTerminalProfile) : undefined
|
||||
const expectedShellPath = profileId !== "default" ? getShellForProfile(profileId) : undefined
|
||||
// Resolve effective shell for comparison (so "default" and "zsh" match on macOS)
|
||||
const effectiveExpected = VscodeTerminalManager.effectiveShellPath(expectedShellPath)
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@ export class SdkTerminalExecutionModeCoordinator {
|
||||
if (previous === next) {
|
||||
return
|
||||
}
|
||||
this.requestRebuild(`Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
}
|
||||
|
||||
Logger.log(`[SdkController] Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
private requestRebuild(reason: string): void {
|
||||
Logger.log(`[SdkController] ${reason}`)
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import { CommandExitError } from "@cline/core"
|
||||
import { EventEmitter } from "events"
|
||||
import * as fs from "fs"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { executeForeground, formatCommandForTerminal, PROCEED_LOG_MAX_BYTES } from "./vscode-run-commands-tool"
|
||||
import {
|
||||
createVscodeRunCommandsTool,
|
||||
executeForeground,
|
||||
formatCommandForTerminal,
|
||||
PROCEED_LOG_MAX_BYTES,
|
||||
} from "./vscode-run-commands-tool"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
existsSync: vi.fn<(path: fs.PathLike) => boolean>(),
|
||||
getGlobalSettingsKey: vi.fn(() => "default"),
|
||||
}))
|
||||
|
||||
vi.mock("fs", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("fs")>()),
|
||||
existsSync: mocks.existsSync,
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({ getGlobalSettingsKey: mocks.getGlobalSettingsKey }),
|
||||
},
|
||||
}))
|
||||
|
||||
// The real telemetry proxy lazily initializes TelemetryService, which requires
|
||||
// a HostProvider that unit tests don't set up.
|
||||
@@ -17,6 +39,74 @@ vi.mock("@services/telemetry", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const originalEnv = { ...process.env }
|
||||
const originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
process.env = { ...originalEnv }
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
mocks.existsSync.mockReset()
|
||||
mocks.getGlobalSettingsKey.mockReset()
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("default")
|
||||
})
|
||||
|
||||
describe("createVscodeRunCommandsTool", () => {
|
||||
it("constructs a cmd tool from the stock array-valued Command Prompt profile", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
process.env.windir = "C:\\Windows"
|
||||
mocks.existsSync.mockImplementation((candidate) => candidate === "C:\\Windows\\System32\\cmd.exe")
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => {
|
||||
if (key === "defaultProfile.windows") {
|
||||
return "Command Prompt"
|
||||
}
|
||||
if (key === "profiles.windows") {
|
||||
return {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}) as never
|
||||
|
||||
const tool = createVscodeRunCommandsTool({
|
||||
cwd: "C:\\workspace",
|
||||
getTerminalManager: () => {
|
||||
throw new Error("Terminal manager should not be created during tool construction")
|
||||
},
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
})
|
||||
|
||||
expect(tool.name).toBe("run_commands")
|
||||
expect(tool.description).toContain("Commands run through cmd.exe")
|
||||
})
|
||||
|
||||
it("re-derives the description from the current profile on each read", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
mocks.existsSync.mockReturnValue(true)
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("cmd")
|
||||
|
||||
const tool = createVscodeRunCommandsTool({
|
||||
cwd: "C:\\workspace",
|
||||
getTerminalManager: () => {
|
||||
throw new Error("Terminal manager should not be created during tool construction")
|
||||
},
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
})
|
||||
expect(tool.description).toContain("Commands run through cmd.exe")
|
||||
|
||||
// A profile change takes effect at the next description read (the
|
||||
// model-request boundary), without a session rebuild.
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("powershell-7")
|
||||
expect(tool.description).toContain("Commands run through PowerShell")
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Minimal fake of the process object returned by VscodeTerminalManager.runCommand():
|
||||
* an EventEmitter that is also awaitable (mirroring mergePromise in
|
||||
@@ -191,6 +281,19 @@ describe("executeForeground", () => {
|
||||
expect(result).toBe("hello")
|
||||
})
|
||||
|
||||
it("passes the caller's terminal profile through to getOrCreateTerminal", async () => {
|
||||
const process = createFakeTerminalProcess({ lines: ["ok"] })
|
||||
const getOrCreateTerminal = vi.fn(async () => ({ terminal: { show: () => {} } }) as never)
|
||||
const terminalManager = {
|
||||
getOrCreateTerminal,
|
||||
runCommand: () => process,
|
||||
} as unknown as VscodeTerminalManager
|
||||
|
||||
await executeForeground("echo ok", "/workspace", terminalManager, 1000, undefined, undefined, "wsl-bash")
|
||||
|
||||
expect(getOrCreateTerminal).toHaveBeenCalledWith("/workspace", "wsl-bash")
|
||||
})
|
||||
|
||||
it("throws CommandExitError with the exit code on non-zero exit", async () => {
|
||||
const terminalManager = createFakeTerminalManager(
|
||||
createFakeTerminalProcess({ lines: ["boom"], completionDetails: { exitCode: 127 } }),
|
||||
|
||||
@@ -165,9 +165,10 @@ export async function executeForeground(
|
||||
maxOutputChars: number,
|
||||
abortSignal?: AbortSignal,
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator,
|
||||
terminalProfileId?: string,
|
||||
): Promise<string> {
|
||||
const terminalCommand = formatCommandForTerminal(command)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd, terminalProfileId)
|
||||
terminalInfo.terminal.show()
|
||||
|
||||
const process = terminalManager.runCommand(terminalInfo, terminalCommand)
|
||||
@@ -289,26 +290,56 @@ export async function executeForeground(
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shell selected by the user's terminal profile setting at one moment:
|
||||
* the profile ID for foreground terminal creation and the shell executable
|
||||
* it resolves to for background spawning and description building.
|
||||
*/
|
||||
interface ShellSnapshot {
|
||||
profileId: string
|
||||
shell: string
|
||||
}
|
||||
|
||||
/** Resolves the shell the user's terminal profile setting selects right now. */
|
||||
function takeShellSnapshot(): ShellSnapshot {
|
||||
// The setting is typed string, but guard empty values the same way the
|
||||
// settings handlers do (they skip persisting "" but older stores may hold one).
|
||||
const profileId = StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") || "default"
|
||||
return { profileId, shell: getShellForProfile(profileId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the custom `run_commands` tool for the VSCode extension.
|
||||
*
|
||||
* This tool suppresses and replaces the SDK's built-in `run_commands` tool.
|
||||
* The terminal execution mode is captured when the session's tool set is built.
|
||||
* Switching modes rebuilds the active SDK session so the tool timeout and
|
||||
* execution mode stay aligned.
|
||||
* The terminal execution mode is captured when the session's tool set is
|
||||
* built; switching modes rebuilds the active SDK session so the tool timeout
|
||||
* and execution path follow it.
|
||||
*
|
||||
* The shell is snapshotted each time the runtime reads the tool description,
|
||||
* which happens when a model request is built. Tool calls produced by that
|
||||
* request execute with the same snapshot, so changing the terminal profile
|
||||
* while the model is generating does not change the shell under commands the
|
||||
* model has already planned: the new shell is named in the next request (the
|
||||
* one carrying these tool results) and used by the commands it produces.
|
||||
*/
|
||||
export function createVscodeRunCommandsTool(options: VscodeRunCommandsToolOptions): AgentTool {
|
||||
return createShellTool(createVscodeShellExecutor(options), {
|
||||
const state = { snapshot: takeShellSnapshot() }
|
||||
return createShellTool(createVscodeShellExecutor(options, state), {
|
||||
cwd: options.cwd,
|
||||
bashTimeoutMs: options.bashTimeoutMs,
|
||||
shell: () => {
|
||||
state.snapshot = takeShellSnapshot()
|
||||
return state.snapshot.shell
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): ShellExecutor {
|
||||
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions, state: { snapshot: ShellSnapshot }): ShellExecutor {
|
||||
const { cwd, getTerminalManager } = options
|
||||
const executionMode = options.vscodeTerminalExecutionMode ?? "backgroundExec"
|
||||
|
||||
// Lazy-init background executor — recreated when the user's shell profile changes.
|
||||
// Lazy-init background executor — recreated when the snapshotted shell changes.
|
||||
let bgExecutor: ShellExecutor | undefined
|
||||
let bgExecutorShell: string | undefined
|
||||
|
||||
@@ -318,12 +349,12 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
return async (command, commandCwd, context): Promise<string> => {
|
||||
Logger.log(`[VscodeRunCommands] Executing command in ${executionMode} mode`)
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor
|
||||
// Resolve shell from the user's terminal profile setting
|
||||
const profileId = (StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") as string) || "default"
|
||||
const shell = getShellForProfile(profileId)
|
||||
// Execute with the shell named in the model request that produced this
|
||||
// tool call, not the setting's current value (see createVscodeRunCommandsTool).
|
||||
const { profileId, shell } = state.snapshot
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor.
|
||||
// Recreate the executor if the shell has changed
|
||||
if (!bgExecutor || bgExecutorShell !== shell) {
|
||||
bgExecutorShell = shell
|
||||
@@ -365,6 +396,7 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
context.signal,
|
||||
options.foregroundCommands,
|
||||
profileId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ export function createShellExecutor() {
|
||||
return async () => ""
|
||||
}
|
||||
|
||||
// The real createShellTool, so tests exercise the actual description
|
||||
// building and shell classification (getShellKind) rather than a stub that
|
||||
// would have to duplicate those invariants.
|
||||
export { createShellTool } from "../../../../sdk/packages/core/src/extensions/tools/definitions"
|
||||
// Real (dependency-light) edit-executor implementations, re-exported from the sdk source so
|
||||
// the diff-edit coordinator and its tests exercise the actual content/parse semantics. These
|
||||
// modules only pull in node:fs/node:path and the patch parser — not the heavy core runtime.
|
||||
@@ -114,13 +118,6 @@ export { createEditorExecutor } from "../../../../sdk/packages/core/src/extensio
|
||||
export type { EditFileInput } from "../../../../sdk/packages/core/src/extensions/tools/schemas"
|
||||
export type { ApplyPatchExecutor, EditorExecutor } from "../../../../sdk/packages/core/src/extensions/tools/types"
|
||||
|
||||
export function createShellTool(execute: unknown) {
|
||||
return {
|
||||
name: "run_commands",
|
||||
execute,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionHistoryRecord {
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import * as actualFs from "fs"
|
||||
import * as actualOs from "os"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
@@ -15,6 +16,16 @@ const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
|
||||
mock.module("os", osMock)
|
||||
mock.module("node:os", osMock)
|
||||
|
||||
// getShell() probes the filesystem for PowerShell 7 when no Windows terminal
|
||||
// profile is configured. Route existsSync through a mutable delegate so tests
|
||||
// control which PowerShell installs "exist" regardless of the host machine.
|
||||
let existsSyncImpl: typeof actualFs.existsSync = actualFs.existsSync
|
||||
const existsSyncDelegate = ((path: unknown) => existsSyncImpl(path as string)) as typeof actualFs.existsSync
|
||||
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate }
|
||||
const fsMock = () => ({ ...fsMockNamespace, default: fsMockNamespace })
|
||||
mock.module("fs", fsMock)
|
||||
mock.module("node:fs", fsMock)
|
||||
|
||||
import { getShell } from "@utils/shell"
|
||||
|
||||
describe("Shell Detection Tests", () => {
|
||||
@@ -22,6 +33,7 @@ describe("Shell Detection Tests", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
let originalGetConfig: typeof vscode.workspace.getConfiguration
|
||||
let originalUserInfo: typeof actualOs.userInfo
|
||||
let originalExistsSync: typeof actualFs.existsSync
|
||||
|
||||
// Helper to mock VS Code configuration
|
||||
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
|
||||
@@ -45,6 +57,7 @@ describe("Shell Detection Tests", () => {
|
||||
originalEnv = { ...process.env }
|
||||
originalGetConfig = vscode.workspace.getConfiguration
|
||||
originalUserInfo = userInfoImpl
|
||||
originalExistsSync = existsSyncImpl
|
||||
|
||||
// Clear environment variables for a clean test
|
||||
delete process.env.SHELL
|
||||
@@ -52,6 +65,9 @@ describe("Shell Detection Tests", () => {
|
||||
|
||||
// Default userInfo() mock
|
||||
userInfoImpl = (() => ({ shell: null })) as any
|
||||
// Default: PowerShell 7 is not installed, so the Windows default
|
||||
// resolves to legacy Windows PowerShell.
|
||||
existsSyncImpl = (() => false) as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,6 +76,7 @@ describe("Shell Detection Tests", () => {
|
||||
process.env = originalEnv
|
||||
vscode.workspace.getConfiguration = originalGetConfig
|
||||
userInfoImpl = originalUserInfo
|
||||
existsSyncImpl = originalExistsSync
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -71,12 +88,63 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses explicit PowerShell 7 path from VS Code config (profile path)", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" },
|
||||
})
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("expands and selects the first configured profile path when it exists", () => {
|
||||
process.env.windir = "C:\\Windows"
|
||||
existsSyncImpl = (() => true) as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\Sysnative\\cmd.exe")
|
||||
})
|
||||
|
||||
it("falls through configured profile paths in order", () => {
|
||||
process.env.windir = "C:\\Windows"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("skips profile paths with variable references it cannot expand", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
process.env.windir = "C:\\Windows"
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${workspaceFolder}\\tools\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("resolves a configured executable name from PATH", () => {
|
||||
process.env.PATH = "C:\\Tools;C:\\Windows\\System32"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": { path: "cmd.exe" },
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => {
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { source: "PowerShell" },
|
||||
@@ -117,18 +185,36 @@ describe("Shell Detection Tests", () => {
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("respects userInfo() if no VS Code config is available", () => {
|
||||
it("defaults to PowerShell 7 when no profile is configured and pwsh is installed", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\PowerShell.exe" }) as any
|
||||
process.env.ProgramW6432 = "C:\\Program Files"
|
||||
existsSyncImpl = (() => true) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Custom\\PowerShell.exe")
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("respects an odd COMSPEC if no userInfo shell is available", () => {
|
||||
it("defaults to Store-installed pwsh when that is the only pwsh present", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
|
||||
existsSyncImpl = ((path: string) => path === storePwsh) as any
|
||||
|
||||
expect(getShell()).to.equal(storePwsh)
|
||||
})
|
||||
|
||||
it("defaults to legacy Windows PowerShell when no profile is configured and pwsh is absent", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
existsSyncImpl = (() => false) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
|
||||
it("ignores userInfo() and COMSPEC — VS Code's default terminal ignores them too", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\OtherShell.exe" }) as any
|
||||
process.env.COMSPEC = "D:\\CustomCmd\\cmd.exe"
|
||||
|
||||
expect(getShell()).to.equal("D:\\CustomCmd\\cmd.exe")
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,12 +227,31 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses VS Code profile path if available", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/local/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: "/usr/local/bin/fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/local/bin/fish")
|
||||
})
|
||||
|
||||
it("expands and selects the first existing path in an array-valued profile", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/zsh") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: ["/opt/homebrew/bin/zsh", "/bin/zsh"] },
|
||||
})
|
||||
expect(getShell()).to.equal("/bin/zsh")
|
||||
})
|
||||
|
||||
it("falls back past a configured path that does not exist", () => {
|
||||
existsSyncImpl = (() => false) as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: "/missing/shell" },
|
||||
})
|
||||
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
|
||||
|
||||
expect(getShell()).to.equal("/opt/homebrew/bin/zsh")
|
||||
})
|
||||
|
||||
it("falls back to userInfo().shell if no VS Code config is available", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
|
||||
@@ -177,12 +282,30 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses VS Code profile path if available", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: "/usr/bin/fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/bin/fish")
|
||||
})
|
||||
|
||||
it("expands and selects the first existing path in an array-valued profile", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/bash") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: ["/usr/bin/fish", "/bin/bash"] },
|
||||
})
|
||||
expect(getShell()).to.equal("/bin/bash")
|
||||
})
|
||||
|
||||
it("resolves a bare executable name from PATH without PATHEXT probing", () => {
|
||||
process.env.PATH = "/opt/tools:/usr/bin"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: "fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/bin/fish")
|
||||
})
|
||||
|
||||
it("falls back to userInfo().shell if no VS Code config is available", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "/usr/bin/zsh" }) as any
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as childProcess from "child_process"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WINDOWS_POWERSHELL_7_PATH, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
import { getWindowsPwshInstallPaths, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
|
||||
const POWERSHELL_PROBE_TIMEOUT_MS = 1200
|
||||
|
||||
@@ -16,14 +16,7 @@ export function getFallbackWindowsPowerShellPath(): string {
|
||||
}
|
||||
|
||||
export function getWindowsPowerShellCandidates(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
|
||||
const envAbsoluteCandidates = [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
WINDOWS_POWERSHELL_7_PATH,
|
||||
WINDOWS_POWERSHELL_LEGACY_PATH,
|
||||
]
|
||||
const envAbsoluteCandidates = [...getWindowsPwshInstallPaths(), WINDOWS_POWERSHELL_LEGACY_PATH]
|
||||
|
||||
const commandNameFallbacks = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"]
|
||||
|
||||
|
||||
+129
-24
@@ -1,5 +1,8 @@
|
||||
import { existsSync } from "fs"
|
||||
import { userInfo } from "os"
|
||||
import * as nodePath from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export const WINDOWS_POWERSHELL_7_PATH = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
|
||||
export const WINDOWS_POWERSHELL_LEGACY_PATH = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
|
||||
@@ -29,21 +32,24 @@ const SHELL_PATHS = {
|
||||
FALLBACK: "/bin/sh",
|
||||
} as const
|
||||
|
||||
// VS Code permits `path: string | string[]` in terminal profiles on every
|
||||
// platform (the stock Windows "Command Prompt" profile is an array), so all
|
||||
// three profile shapes model both forms.
|
||||
interface MacTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
}
|
||||
|
||||
type MacTerminalProfiles = Record<string, MacTerminalProfile>
|
||||
|
||||
interface WindowsTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
source?: "PowerShell" | "WSL"
|
||||
}
|
||||
|
||||
type WindowsTerminalProfiles = Record<string, WindowsTerminalProfile>
|
||||
|
||||
interface LinuxTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
}
|
||||
|
||||
type LinuxTerminalProfiles = Record<string, LinuxTerminalProfile>
|
||||
@@ -89,6 +95,83 @@ function getLinuxTerminalConfig() {
|
||||
// 2) Platform-Specific VS Code Shell Retrieval
|
||||
// -----------------------------------------------------
|
||||
|
||||
function isWindows(): boolean {
|
||||
return process.platform === "win32"
|
||||
}
|
||||
|
||||
/** The path module matching the host platform's separators and semantics. */
|
||||
function hostPath(): nodePath.PlatformPath {
|
||||
return isWindows() ? nodePath.win32 : nodePath.posix
|
||||
}
|
||||
|
||||
function getEnvironmentVariable(name: string): string | undefined {
|
||||
// Windows environment variable names are case-insensitive; POSIX names
|
||||
// are case-sensitive.
|
||||
if (!isWindows()) {
|
||||
return process.env[name]
|
||||
}
|
||||
const entry = Object.entries(process.env).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
return entry?.[1]
|
||||
}
|
||||
|
||||
/** Expands VS Code's `${env:NAME}` references in a profile path. */
|
||||
function expandShellPath(candidate: string): string {
|
||||
return candidate.replace(/\$\{env:([^}]+)\}/gi, (reference, name: string) => {
|
||||
return getEnvironmentVariable(name.trim()) ?? reference
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a profile path candidate to an existing executable: absolute or
|
||||
* relative paths are checked directly; bare names are searched on PATH
|
||||
* (with PATHEXT on Windows, mirroring how VS Code launches profiles).
|
||||
*/
|
||||
function findExecutable(candidate: string): string | null {
|
||||
const path = hostPath()
|
||||
if (path.basename(candidate) !== candidate) {
|
||||
const normalized = path.normalize(candidate)
|
||||
return existsSync(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
const pathValue = getEnvironmentVariable("PATH")
|
||||
if (!pathValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const extensions =
|
||||
!isWindows() || path.extname(candidate) ? [""] : (getEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";")
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
for (const extension of extensions) {
|
||||
const executable = path.join(directory, `${candidate}${extension}`)
|
||||
if (existsSync(executable)) {
|
||||
return executable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolves the first usable path in a VS Code terminal profile. */
|
||||
function resolveShellPath(configuredPath: string | string[] | undefined): string | null {
|
||||
const candidates = typeof configuredPath === "string" ? [configuredPath] : configuredPath
|
||||
for (const candidate of candidates ?? []) {
|
||||
const expandedPath = expandShellPath(candidate)
|
||||
if (expandedPath.includes("${")) {
|
||||
// Only ${env:NAME} references are expanded here. VS Code resolves
|
||||
// more variable kinds (e.g. ${workspaceFolder}); surface the gap
|
||||
// instead of silently skipping the user's configured shell.
|
||||
Logger.warn(`[shell] Skipping terminal profile path with unresolved variable reference: ${candidate}`)
|
||||
continue
|
||||
}
|
||||
const executable = findExecutable(expandedPath)
|
||||
if (executable) {
|
||||
return executable
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Attempts to retrieve a shell path from VS Code config on Windows. */
|
||||
function getWindowsShellFromVSCode(): string | null {
|
||||
const { defaultProfileName, profiles } = getWindowsTerminalConfig()
|
||||
@@ -97,14 +180,15 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
const configuredShell = resolveShellPath(profile?.path)
|
||||
|
||||
// If the profile name indicates PowerShell, do version-based detection.
|
||||
// In testing it was found these typically do not have a path, and this
|
||||
// implementation manages to deductively get the correct version of PowerShell
|
||||
if (defaultProfileName.toLowerCase().includes("powershell")) {
|
||||
if (profile?.path) {
|
||||
if (configuredShell) {
|
||||
// If there's an explicit PowerShell path, return that
|
||||
return profile.path
|
||||
return configuredShell
|
||||
}
|
||||
if (profile?.source === "PowerShell") {
|
||||
// If the profile is sourced from PowerShell, assume the newest
|
||||
@@ -115,8 +199,8 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
}
|
||||
|
||||
// If there's a specific path, return that immediately
|
||||
if (profile?.path) {
|
||||
return profile.path
|
||||
if (configuredShell) {
|
||||
return configuredShell
|
||||
}
|
||||
|
||||
// If the profile indicates WSL
|
||||
@@ -135,8 +219,7 @@ function getMacShellFromVSCode(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
return profile?.path || null
|
||||
return resolveShellPath(profiles[defaultProfileName]?.path)
|
||||
}
|
||||
|
||||
/** Attempts to retrieve a shell path from VS Code config on Linux. */
|
||||
@@ -146,8 +229,7 @@ function getLinuxShellFromVSCode(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
return profile?.path || null
|
||||
return resolveShellPath(profiles[defaultProfileName]?.path)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
@@ -171,11 +253,6 @@ function getShellFromUserInfo(): string | null {
|
||||
function getShellFromEnv(): string | null {
|
||||
const { env } = process
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, COMSPEC typically holds cmd.exe
|
||||
return env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS/Linux, SHELL is commonly the environment variable
|
||||
return env.SHELL || "/bin/zsh"
|
||||
@@ -304,6 +381,35 @@ export function getShellForProfile(profileId: string): string {
|
||||
// 5) Publicly Exposed Shell Getter
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Absolute paths where a modern PowerShell (pwsh) may be installed, most
|
||||
* preferred first: MSI/ZIP installs under Program Files (either architecture),
|
||||
* then the Microsoft Store install under LOCALAPPDATA. This is the single
|
||||
* candidate list shared with the async prober in utils/powershell.ts.
|
||||
*/
|
||||
export function getWindowsPwshInstallPaths(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
return [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
SHELL_PATHS.POWERSHELL_7,
|
||||
...(localAppData ? [`${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell VS Code launches on Windows when the user has not configured a
|
||||
* default terminal profile: its built-in default is PowerShell (pwsh when
|
||||
* installed, Windows PowerShell otherwise) — never cmd.exe. Mirroring that
|
||||
* here keeps the "default" profile meaning the same shell whether commands
|
||||
* run in a visible VS Code terminal or a background child process.
|
||||
*/
|
||||
function getWindowsDefaultShell(): string {
|
||||
const pwsh = getWindowsPwshInstallPaths().find((candidate) => existsSync(candidate))
|
||||
return pwsh ?? SHELL_PATHS.POWERSHELL_LEGACY
|
||||
}
|
||||
|
||||
export function getShell(): string {
|
||||
// 1. Check VS Code config first.
|
||||
if (process.platform === "win32") {
|
||||
@@ -312,7 +418,12 @@ export function getShell(): string {
|
||||
if (windowsShell) {
|
||||
return windowsShell
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
// No profile configured — match the shell VS Code's default terminal
|
||||
// would launch. userInfo()/COMSPEC are not consulted: VS Code's own
|
||||
// terminal ignores them too, and they would resolve to cmd.exe.
|
||||
return getWindowsDefaultShell()
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
// macOS from VS Code
|
||||
const macShell = getMacShellFromVSCode()
|
||||
if (macShell) {
|
||||
@@ -338,12 +449,6 @@ export function getShell(): string {
|
||||
return envShell
|
||||
}
|
||||
|
||||
// 4. Finally, fall back to a default
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, if we got here, we have no config, no COMSPEC, and one very messed up operating system.
|
||||
// Use CMD as a last resort
|
||||
return SHELL_PATHS.CMD
|
||||
}
|
||||
// On macOS/Linux, fallback to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
// 4. Fall back to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
return SHELL_PATHS.FALLBACK
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ ClinePass includes the following models, tested and benchmarked for coding agent
|
||||
| Model | Model ID |
|
||||
|-------|------------|
|
||||
| GLM-5.2 | `cline-pass/glm-5.2` |
|
||||
| Kimi K3 | `cline-pass/kimi-k3` |
|
||||
| Kimi K2.7 Code | `cline-pass/kimi-k2.7-code` |
|
||||
| Kimi K2.6 | `cline-pass/kimi-k2.6` |
|
||||
| DeepSeek V4 Pro | `cline-pass/deepseek-v4-pro` |
|
||||
@@ -91,6 +92,7 @@ ClinePass is a flat monthly subscription, so you are not charged the individual
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
|-------|-------|--------|-------------|--------------|
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - |
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.65
|
||||
|
||||
- Claude Code and Codex provider SDKs are now optional peer dependencies loaded on demand, dramatically cutting install size
|
||||
- Added Kimi K3 to the bundled ClinePass model fallback
|
||||
- Runs now retry once after refreshing expired OAuth credentials
|
||||
- Team runs: the spawn tool is no longer exposed to teammate agents
|
||||
- Team runs: errored teammate runs now report as failed instead of completed
|
||||
- Improved shell-command parsing to fix a Windows shell mismatch
|
||||
- New `@cline/ui` agent chat components with Storybook and npm packaging
|
||||
- Updated the bundled model catalog
|
||||
|
||||
## 0.0.64
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getToolContextTelemetry,
|
||||
} from "../../services/telemetry/tool-context";
|
||||
import {
|
||||
buildRunCommandsDescription,
|
||||
createDefaultTools,
|
||||
createReadFilesTool,
|
||||
createSearchTool,
|
||||
@@ -480,6 +481,71 @@ describe("default apply_patch tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("run_commands tool description", () => {
|
||||
it("names PowerShell with ';' sequencing for PowerShell shells", () => {
|
||||
const description = buildRunCommandsDescription("powershell", true);
|
||||
expect(description).toContain("Commands run through PowerShell");
|
||||
expect(description).toContain("use ';' to sequence commands");
|
||||
expect(description).toContain("in Windows environment");
|
||||
});
|
||||
|
||||
it("names cmd.exe with '&&' sequencing for cmd shells", () => {
|
||||
const description = buildRunCommandsDescription("cmd", true);
|
||||
expect(description).toContain("Commands run through cmd.exe");
|
||||
expect(description).toContain("use '&&' to sequence commands");
|
||||
expect(description).not.toContain("PowerShell");
|
||||
});
|
||||
|
||||
it("describes WSL bash with the /mnt working-directory mapping", () => {
|
||||
const description = buildRunCommandsDescription("wsl", true);
|
||||
expect(description).toContain("bash in WSL");
|
||||
expect(description).toContain("/mnt/<drive>");
|
||||
expect(description).not.toContain("PowerShell");
|
||||
});
|
||||
|
||||
it("notes the Windows host for POSIX shells on Windows only", () => {
|
||||
const onWindows = buildRunCommandsDescription("posix", true);
|
||||
expect(onWindows).toContain("POSIX (bash-compatible) shell on Windows");
|
||||
expect(onWindows).not.toContain("PowerShell");
|
||||
|
||||
const onUnix = buildRunCommandsDescription("posix", false);
|
||||
expect(onUnix).not.toContain("Windows");
|
||||
expect(onUnix).toContain("grep/head/tail");
|
||||
});
|
||||
|
||||
it("derives the createShellTool description from config.shell", () => {
|
||||
const posixTool = createShellTool(async () => "ok", {
|
||||
shell: "/bin/bash",
|
||||
});
|
||||
expect(posixTool.description).toContain(
|
||||
"Run non-interactive shell commands",
|
||||
);
|
||||
expect(posixTool.description).not.toContain("PowerShell");
|
||||
|
||||
const cmdTool = createShellTool(async () => "ok", {
|
||||
shell: "C:\\Windows\\System32\\cmd.exe",
|
||||
});
|
||||
expect(cmdTool.description).toContain("Commands run through cmd.exe");
|
||||
});
|
||||
|
||||
it("re-derives the description on each read when config.shell is a provider", () => {
|
||||
let shell = "/bin/bash";
|
||||
const tool = createShellTool(async () => "ok", {
|
||||
shell: () => shell,
|
||||
});
|
||||
expect(tool.description).not.toContain("PowerShell");
|
||||
|
||||
shell = "powershell.exe";
|
||||
expect(tool.description).toContain("Commands run through PowerShell");
|
||||
|
||||
// The property must survive the shallow copy the runtime performs when
|
||||
// building AgentToolDefinitions for a model request.
|
||||
shell = "cmd.exe";
|
||||
const definition = { ...tool };
|
||||
expect(definition.description).toContain("Commands run through cmd.exe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default run_commands tool", () => {
|
||||
function createTelemetryStub(): ITelemetryService {
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type AgentTool,
|
||||
type AgentToolContext,
|
||||
createTool,
|
||||
getDefaultShell,
|
||||
getShellKind,
|
||||
type ShellKind,
|
||||
validateWithZod,
|
||||
zodToJsonSchema,
|
||||
} from "@cline/shared";
|
||||
@@ -395,15 +398,64 @@ const RUN_COMMANDS_SHARED_INSTRUCTIONS =
|
||||
"Use for listing files, checking git status, running builds, executing tests, etc. " +
|
||||
"Commands must be non-interactive. Commands that require follow-up input like pagers should be skipped or used with supported flags/env (e.g. git --no-pager, --non-interactive) to bypass the interaction steps. ";
|
||||
|
||||
/**
|
||||
* Build the run_commands tool description for the shell that will actually
|
||||
* execute the commands. The shell kind decides the syntax guidance (quoting,
|
||||
* sequencing, heredocs), and isWindows adds environment context for POSIX
|
||||
* shells running on a Windows host (e.g. Git Bash).
|
||||
*/
|
||||
export function buildRunCommandsDescription(
|
||||
shellKind: ShellKind,
|
||||
isWindows: boolean,
|
||||
): string {
|
||||
if (shellKind === "powershell" || shellKind === "cmd") {
|
||||
const shellName = shellKind === "powershell" ? "PowerShell" : "cmd.exe";
|
||||
const sequencingOperator = shellKind === "powershell" ? "';'" : "'&&'";
|
||||
return (
|
||||
"Run non-interactive shell commands from the root of the workspace in Windows environment. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
|
||||
`Commands run through ${shellName}; quote paths and arguments for ${shellName} and use ${sequencingOperator} to sequence commands. ` +
|
||||
"Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
);
|
||||
}
|
||||
|
||||
const environmentNote =
|
||||
shellKind === "wsl"
|
||||
? "Commands run through bash in WSL (wsl.exe); the Windows working directory is mounted under /mnt/<drive>. "
|
||||
: isWindows
|
||||
? "Commands run through a POSIX (bash-compatible) shell on Windows. "
|
||||
: "";
|
||||
return (
|
||||
"Run non-interactive shell commands from the root of the workspace. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
environmentNote +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
|
||||
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the run_commands shell tool for the current platform.
|
||||
*
|
||||
* This preserves the SDK's platform-specific prompting/schema choices while
|
||||
* exposing a single generic shell-tool factory for host integrations.
|
||||
* exposing a single generic shell-tool factory for host integrations. Pass
|
||||
* config.shell (matching the executor's shell) so the syntax guidance in the
|
||||
* tool description matches the shell that actually runs the commands.
|
||||
*
|
||||
* config.shell may be a provider function instead of a string. The runtime
|
||||
* reads `description` when building each model request, so a provider is
|
||||
* consulted at that boundary: a shell change made while the model is
|
||||
* generating does not affect the request in flight, and the next request
|
||||
* names the new shell. The provider must return the shell the executor will
|
||||
* use for tool calls issued by that next request.
|
||||
*/
|
||||
export function createShellTool(
|
||||
executor: ShellExecutor,
|
||||
config: Pick<DefaultToolsConfig, "cwd" | "bashTimeoutMs"> = {},
|
||||
config: Pick<DefaultToolsConfig, "cwd" | "bashTimeoutMs"> & {
|
||||
shell?: string | (() => string);
|
||||
} = {},
|
||||
): AgentTool<unknown, ToolOperationResult[]> {
|
||||
const timeoutMs = config.bashTimeoutMs ?? 30000;
|
||||
const timeoutSource =
|
||||
@@ -412,19 +464,17 @@ export function createShellTool(
|
||||
: "configured_setting";
|
||||
const cwd = config.cwd ?? process.cwd();
|
||||
const isWindows = process.platform === "win32";
|
||||
const configShell = config.shell;
|
||||
const resolveShell =
|
||||
typeof configShell === "function"
|
||||
? configShell
|
||||
: () => configShell ?? getDefaultShell(process.platform);
|
||||
const describe = () =>
|
||||
buildRunCommandsDescription(getShellKind(resolveShell()), isWindows);
|
||||
|
||||
return createTool<unknown, ToolOperationResult[]>({
|
||||
const tool = createTool<unknown, ToolOperationResult[]>({
|
||||
name: "run_commands",
|
||||
description: isWindows
|
||||
? "Run non-interactive shell commands from the root of the workspace in Windows environment. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
|
||||
"Commands run through PowerShell; quote paths and arguments for PowerShell and use ';' to sequence commands. Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
: "Run non-interactive shell commands from the root of the workspace. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
|
||||
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later.",
|
||||
description: describe(),
|
||||
inputSchema: zodToJsonSchema(RunCommandsInputSchema),
|
||||
timeoutMs: timeoutMs * 2,
|
||||
retryable: false,
|
||||
@@ -443,6 +493,17 @@ export function createShellTool(
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof configShell === "function") {
|
||||
// The runtime rebuilds tool definitions from this property for every
|
||||
// model request, so a getter re-derives the description at exactly the
|
||||
// send-to-model boundary. AgentTool consumers only read `description`.
|
||||
Object.defineProperty(tool, "description", {
|
||||
get: describe,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AgentToolContext } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawn = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn }));
|
||||
|
||||
import { createBuiltinTools } from "./index";
|
||||
|
||||
const context: AgentToolContext = {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
function createSuccessfulChildProcess(): ChildProcessWithoutNullStreams {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
stdin: new EventEmitter(),
|
||||
pid: 123,
|
||||
kill: vi.fn(() => true),
|
||||
});
|
||||
queueMicrotask(() => child.emit("close", 0));
|
||||
return child as unknown as ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
async function executeRunCommands(
|
||||
options: Parameters<typeof createBuiltinTools>[0],
|
||||
) {
|
||||
const tool = createBuiltinTools(options).find(
|
||||
(candidate) => candidate.name === "run_commands",
|
||||
);
|
||||
if (!tool) {
|
||||
throw new Error("Expected run_commands tool");
|
||||
}
|
||||
|
||||
await tool.execute({ commands: ["echo ok"] }, context);
|
||||
return tool;
|
||||
}
|
||||
|
||||
describe("createBuiltinTools shell configuration", () => {
|
||||
beforeEach(() => {
|
||||
spawn.mockReset();
|
||||
spawn.mockImplementation(() => createSuccessfulChildProcess());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "top-level shell",
|
||||
options: { shell: "cmd.exe" },
|
||||
expectedShell: "cmd.exe",
|
||||
expectedDescription: "Commands run through cmd.exe",
|
||||
},
|
||||
{
|
||||
name: "executor shell",
|
||||
options: { executorOptions: { bash: { shell: "powershell.exe" } } },
|
||||
expectedShell: "powershell.exe",
|
||||
expectedDescription: "Commands run through PowerShell",
|
||||
},
|
||||
{
|
||||
name: "top-level shell precedence",
|
||||
options: {
|
||||
shell: "cmd.exe",
|
||||
executorOptions: { bash: { shell: "powershell.exe" } },
|
||||
},
|
||||
expectedShell: "cmd.exe",
|
||||
expectedDescription: "Commands run through cmd.exe",
|
||||
},
|
||||
])("uses the $name for both description and execution", async ({
|
||||
options,
|
||||
expectedShell,
|
||||
expectedDescription,
|
||||
}) => {
|
||||
const tool = await executeRunCommands(options);
|
||||
|
||||
expect(tool.description).toContain(expectedDescription);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expectedShell,
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -122,7 +122,7 @@ export type {
|
||||
// Convenience: Create Tools with Built-in Executors
|
||||
// =============================================================================
|
||||
|
||||
import type { AgentTool } from "@cline/shared";
|
||||
import { type AgentTool, getDefaultShell } from "@cline/shared";
|
||||
import { createDefaultTools } from "./definitions";
|
||||
import {
|
||||
createDefaultExecutors,
|
||||
@@ -136,11 +136,16 @@ import type { CreateDefaultToolsOptions, ToolExecutors } from "./types";
|
||||
export interface CreateBuiltinToolsOptions
|
||||
extends Omit<CreateDefaultToolsOptions, "executors"> {
|
||||
/**
|
||||
* Configuration for the built-in executors
|
||||
* Configuration for the built-in executors. `bash.shell` is used when the
|
||||
* top-level `shell` option is not set; the top-level option takes precedence.
|
||||
*/
|
||||
executorOptions?: DefaultExecutorsOptions;
|
||||
/**
|
||||
* Optional executor overrides/additions for tools without built-ins
|
||||
* Optional executor overrides/additions for tools without built-ins.
|
||||
* An overriding `bash` executor replaces the built-in one wholesale: it
|
||||
* decides its own shell, and the resolved `shell` option only shapes the
|
||||
* run_commands description. Overriders must honor that shell themselves
|
||||
* to keep the description truthful.
|
||||
*/
|
||||
executors?: Partial<ToolExecutors>;
|
||||
}
|
||||
@@ -180,14 +185,29 @@ export function createBuiltinTools(
|
||||
executors: executorOverrides,
|
||||
...toolsConfig
|
||||
} = options;
|
||||
// The top-level shell is the public tool configuration and takes precedence
|
||||
// over the legacy executor-specific location. Resolve it once so prompting
|
||||
// and execution cannot disagree.
|
||||
const shell =
|
||||
toolsConfig.shell ??
|
||||
executorOptions.bash?.shell ??
|
||||
getDefaultShell(process.platform);
|
||||
const resolvedExecutorOptions: DefaultExecutorsOptions = {
|
||||
...executorOptions,
|
||||
bash: {
|
||||
...executorOptions.bash,
|
||||
shell,
|
||||
},
|
||||
};
|
||||
|
||||
const executors = {
|
||||
...createDefaultExecutors(executorOptions),
|
||||
...createDefaultExecutors(resolvedExecutorOptions),
|
||||
...(executorOverrides ?? {}),
|
||||
};
|
||||
|
||||
return createDefaultTools({
|
||||
...toolsConfig,
|
||||
shell,
|
||||
executors,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type DelegatedAgentConnectionConfig = Pick<
|
||||
| "apiKey"
|
||||
| "baseUrl"
|
||||
| "headers"
|
||||
| "onAuthError"
|
||||
| "providerConfig"
|
||||
| "knownModels"
|
||||
| "thinking"
|
||||
@@ -88,6 +89,7 @@ export function createDelegatedAgentConfigProvider(
|
||||
apiKey: runtimeConfig.apiKey,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
headers: runtimeConfig.headers,
|
||||
onAuthError: runtimeConfig.onAuthError,
|
||||
providerConfig: runtimeConfig.providerConfig,
|
||||
knownModels: runtimeConfig.knownModels,
|
||||
thinking: runtimeConfig.thinking,
|
||||
|
||||
@@ -647,3 +647,61 @@ describe("AgentTeamsRuntime teammate lifecycle events", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTeamsRuntime run failure reporting", () => {
|
||||
it("marks a run failed when the teammate result finishes with an error", async () => {
|
||||
const events: TeamEvent[] = [];
|
||||
// biome-ignore lint/complexity/useArrowFunction: `new SessionRuntime(...)` requires a non-arrow callable.
|
||||
createSessionRuntimeMock.mockImplementationOnce(function () {
|
||||
return {
|
||||
abort: vi.fn(),
|
||||
run: vi.fn(async () => ({
|
||||
text: "Unauthorized: Please re-authenticate your Cline account.",
|
||||
iterations: 8,
|
||||
finishReason: "error",
|
||||
durationMs: 100,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
})),
|
||||
continue: vi.fn(),
|
||||
canStartRun: vi.fn(() => true),
|
||||
getAgentId: vi.fn(() => "teammate-1"),
|
||||
getConversationId: vi.fn(() => "conv-1"),
|
||||
getMessages: vi.fn(() => []),
|
||||
subscribeEvents: vi.fn(() => () => {}),
|
||||
};
|
||||
});
|
||||
const runtime = new AgentTeamsRuntime({
|
||||
teamName: "test-team",
|
||||
onTeamEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
runtime.spawnTeammate({
|
||||
agentId: "alice",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-5-20250929",
|
||||
systemPrompt: "Helper teammate",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
const run = runtime.startTeammateRun("alice", "do the thing");
|
||||
const settledRun = await runtime.awaitRun(run.id, 1);
|
||||
|
||||
expect(settledRun.status).toBe("failed");
|
||||
expect(settledRun.error).toContain("Unauthorized");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: TeamMessageType.RunFailed,
|
||||
run: expect.objectContaining({ id: run.id, status: "failed" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1218,6 +1218,13 @@ export class AgentTeamsRuntime {
|
||||
taskId: run.taskId,
|
||||
continueConversation: run.continueConversation,
|
||||
});
|
||||
// Model-stream failures surface as results with finishReason
|
||||
// "error" rather than throws; route them through the failure
|
||||
// path so the run is reported as failed (and retried when
|
||||
// maxRetries allows) instead of masquerading as completed.
|
||||
if (result.finishReason === "error") {
|
||||
throw new Error(result.text || "Teammate run failed");
|
||||
}
|
||||
run.status = "completed";
|
||||
run.result = result;
|
||||
run.endedAt = new Date();
|
||||
|
||||
@@ -230,6 +230,10 @@ function spawnTeamTeammate(
|
||||
teammateConfigProvider: options.teammateConfigProvider,
|
||||
createBaseTools: options.createBaseTools,
|
||||
allowSpawn: false,
|
||||
// Spawning is lead-only; exposing the tool to teammates just
|
||||
// makes them burn turns on "Only the lead agent can manage
|
||||
// teammates." rejections.
|
||||
includeSpawnTool: false,
|
||||
}),
|
||||
);
|
||||
options.runtime.spawnTeammate({
|
||||
|
||||
@@ -297,6 +297,14 @@ export interface DefaultToolsConfig {
|
||||
*/
|
||||
cwd?: string;
|
||||
|
||||
/**
|
||||
* Shell executable (name or full path) the run_commands executor will use.
|
||||
* The tool description tells the model which shell syntax to write, so this
|
||||
* must match the shell configured on the executor.
|
||||
* @default getDefaultShell(process.platform) — "/bin/bash" on Unix, "powershell" on Windows
|
||||
*/
|
||||
shell?: string;
|
||||
|
||||
/**
|
||||
* Timeout for file read operations in milliseconds
|
||||
* @default 10000
|
||||
|
||||
@@ -495,6 +495,27 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
configWithProvider.teamName = runtime.teamRuntime.getTeamName();
|
||||
}
|
||||
|
||||
// Auth-retry hook for every agent in the session (lead, teammates,
|
||||
// subagents): refresh OAuth credentials and propagate the new key to
|
||||
// all connections, then let the runtime retry the failed run. Without
|
||||
// this, a token that expires while the lead is blocked (e.g. in
|
||||
// team_await_runs) kills teammate runs with a raw provider 401.
|
||||
const onAuthError = async (): Promise<boolean> => {
|
||||
const liveSession = this.sessions.get(sessionId);
|
||||
if (!liveSession || !isOAuthProvider(liveSession.config.providerId)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.syncOAuthCredentials(liveSession, { forceRefresh: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
runtime.delegatedAgentConfigProvider?.updateConnectionDefaults({
|
||||
onAuthError,
|
||||
});
|
||||
|
||||
const tools = [...runtime.tools, ...(configWithProvider.extraTools ?? [])];
|
||||
const extensions = runtime.extensions ?? bootstrap.extensions;
|
||||
const explicitInitialCompactionState = startInput.initialCompactionState;
|
||||
@@ -564,6 +585,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
apiKey: providerConfig.apiKey,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
headers: providerConfig.headers,
|
||||
onAuthError,
|
||||
knownModels: providerConfig.knownModels,
|
||||
providerConfig,
|
||||
thinking: configWithProvider.thinking,
|
||||
|
||||
@@ -2397,3 +2397,90 @@ describe("SessionRuntime.run — tracker wiring (P1 #3)", () => {
|
||||
expect(abortCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SessionRuntime auth retry", () => {
|
||||
const authFailure: Partial<AgentRunResult> = {
|
||||
status: "failed",
|
||||
error: new Error(
|
||||
"Unauthorized: Please make sure you're using the latest version of Cline and re-authenticate your Cline account.",
|
||||
),
|
||||
};
|
||||
|
||||
/** Runtime factory that scripts each successive AgentRuntime build. */
|
||||
function withSequencedRuntimes(scripts: FakeAgentRuntimeScript[]): {
|
||||
deps: SessionRuntimeOrchestratorDeps;
|
||||
createdCount: () => number;
|
||||
} {
|
||||
let created = 0;
|
||||
const deps: SessionRuntimeOrchestratorDeps = {
|
||||
createAgentRuntimeImpl: () => {
|
||||
const script = scripts[Math.min(created, scripts.length - 1)];
|
||||
created += 1;
|
||||
return makeFakeAgentRuntime(script).runtime;
|
||||
},
|
||||
};
|
||||
return { deps, createdCount: () => created };
|
||||
}
|
||||
|
||||
it("retries once with refreshed credentials when a run fails with an auth error", async () => {
|
||||
const onAuthError = vi.fn(async () => true);
|
||||
const capture = vi.fn();
|
||||
const telemetry = { capture } as unknown as AgentConfig["telemetry"];
|
||||
const { deps, createdCount } = withSequencedRuntimes([
|
||||
{ result: authFailure },
|
||||
{ result: { outputText: "recovered" } },
|
||||
]);
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({ onAuthError, telemetry }),
|
||||
deps,
|
||||
);
|
||||
|
||||
const result = await session.run("go");
|
||||
|
||||
expect(onAuthError).toHaveBeenCalledTimes(1);
|
||||
expect(createdCount()).toBe(2);
|
||||
expect(result.finishReason).toBe("completed");
|
||||
expect(result.text).toBe("recovered");
|
||||
expect(capture).toHaveBeenCalledWith({
|
||||
event: "user.auth_run_retry",
|
||||
properties: { provider: "anthropic", recovered: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the failed result when the host cannot refresh credentials", async () => {
|
||||
const onAuthError = vi.fn(async () => false);
|
||||
const { deps, createdCount } = withSequencedRuntimes([
|
||||
{ result: authFailure },
|
||||
]);
|
||||
const session = new SessionRuntime(makeAgentConfig({ onAuthError }), deps);
|
||||
|
||||
const result = await session.run("go");
|
||||
|
||||
expect(onAuthError).toHaveBeenCalledTimes(1);
|
||||
expect(createdCount()).toBe(1);
|
||||
expect(result.finishReason).toBe("error");
|
||||
});
|
||||
|
||||
it("does not invoke onAuthError for non-auth failures", async () => {
|
||||
const onAuthError = vi.fn(async () => true);
|
||||
const { deps, createdCount } = withSequencedRuntimes([
|
||||
{
|
||||
result: {
|
||||
status: "failed",
|
||||
error: new Error("Model stream failed"),
|
||||
},
|
||||
},
|
||||
]);
|
||||
const session = new SessionRuntime(makeAgentConfig({ onAuthError }), deps);
|
||||
|
||||
const result = await session.run("go");
|
||||
|
||||
expect(onAuthError).not.toHaveBeenCalled();
|
||||
expect(createdCount()).toBe(1);
|
||||
expect(result.finishReason).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type ContributionRegistry,
|
||||
createContributionRegistry,
|
||||
type ITelemetryService,
|
||||
isLikelyAuthError,
|
||||
type LegacyAgentUsage,
|
||||
type LoopDetectionConfig,
|
||||
type Message,
|
||||
@@ -52,7 +53,10 @@ import {
|
||||
createAgentModelFromConfig,
|
||||
resolveKnownModelsFromConfig,
|
||||
} from "../../services/llms/handler-factory";
|
||||
import { captureMistakeLimitReached } from "../../services/telemetry/core-events";
|
||||
import {
|
||||
captureAuthRunRetry,
|
||||
captureMistakeLimitReached,
|
||||
} from "../../services/telemetry/core-events";
|
||||
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
|
||||
import {
|
||||
getMessageBuilderOptionsFromEnv,
|
||||
@@ -691,7 +695,7 @@ export class SessionRuntime {
|
||||
isContinue: boolean;
|
||||
}): Promise<AgentResult> {
|
||||
let activePromise!: Promise<AgentResult>;
|
||||
activePromise = this.executeRunInternal(input).finally(() => {
|
||||
activePromise = this.executeRunWithAuthRetry(input).finally(() => {
|
||||
if (this.activeRunPromise === activePromise) {
|
||||
this.activeRunPromise = null;
|
||||
}
|
||||
@@ -700,6 +704,37 @@ export class SessionRuntime {
|
||||
return activePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a run once when it failed with an auth-like error and the host
|
||||
* refreshed credentials via `config.onAuthError`. The failed attempt's
|
||||
* trail is already persisted to the conversation store, so the retry
|
||||
* continues from where the stream died instead of replaying the run.
|
||||
*/
|
||||
private async executeRunWithAuthRetry(input: {
|
||||
userMessage?: string;
|
||||
userImages?: string[];
|
||||
userFiles?: string[];
|
||||
isContinue: boolean;
|
||||
}): Promise<AgentResult> {
|
||||
const result = await this.executeRunInternal(input);
|
||||
if (
|
||||
result.finishReason !== "error" ||
|
||||
!this.config.onAuthError ||
|
||||
!isLikelyAuthError(result.text)
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
const refreshed = await this.config.onAuthError().catch(() => false);
|
||||
if (!refreshed) {
|
||||
return result;
|
||||
}
|
||||
const retryResult = await this.executeRunInternal({ isContinue: true });
|
||||
captureAuthRunRetry(this.telemetry, this.config.providerId, {
|
||||
recovered: retryResult.finishReason !== "error",
|
||||
});
|
||||
return retryResult;
|
||||
}
|
||||
|
||||
private async executeRunInternal(input: {
|
||||
userMessage?: string;
|
||||
userImages?: string[];
|
||||
|
||||
@@ -49,6 +49,7 @@ export const CORE_TELEMETRY_EVENTS = {
|
||||
AUTH_FAILED: "user.auth_failed",
|
||||
AUTH_LOGGED_OUT: "user.auth_logged_out",
|
||||
AUTH_REFRESH_SOFT_FAILURE: "user.auth_refresh_soft_failure",
|
||||
AUTH_RUN_RETRY: "user.auth_run_retry",
|
||||
PROVIDER_CONFIGURED: "user.provider_configured",
|
||||
TELEMETRY_OPT_OUT: "user.opt_out",
|
||||
},
|
||||
@@ -291,6 +292,23 @@ export function captureAuthRefreshSoftFailure(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when a run failed with an auth-like error, credentials were
|
||||
* refreshed, and the run was retried once. `recovered: true` means the retry
|
||||
* completed — a run that would previously have surfaced a raw provider 401
|
||||
* (e.g. a teammate stranded on a spawn-time token snapshot past its TTL).
|
||||
*/
|
||||
export function captureAuthRunRetry(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
provider?: string,
|
||||
details?: { recovered?: boolean },
|
||||
): void {
|
||||
emit(telemetry, CORE_TELEMETRY_EVENTS.USER.AUTH_RUN_RETRY, {
|
||||
provider,
|
||||
recovered: details?.recovered,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when the user finishes configuring a "bring your own provider"
|
||||
* (API-key based) provider during onboarding or via settings.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -41,6 +41,12 @@
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/client-bedrock-runtime": {
|
||||
"optional": true
|
||||
},
|
||||
"ai-sdk-provider-claude-code": {
|
||||
"optional": true
|
||||
},
|
||||
"ai-sdk-provider-codex-cli": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -61,14 +67,18 @@
|
||||
"@streamparser/json": "^0.0.21",
|
||||
"ai": "^6.0.144",
|
||||
"ai-sdk-ollama": "^3.8.8",
|
||||
"ai-sdk-provider-claude-code": "^3.4.3",
|
||||
"ai-sdk-provider-codex-cli": "^1.1.0",
|
||||
"ai-sdk-provider-opencode-sdk": "^3.0.1",
|
||||
"dify-ai-provider": "^1.1.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.0.0"
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.0.0",
|
||||
"ai-sdk-provider-claude-code": "^3.4.3",
|
||||
"ai-sdk-provider-codex-cli": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ai-sdk-provider-claude-code": "^3.4.3",
|
||||
"ai-sdk-provider-codex-cli": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
type ProviderCapability,
|
||||
type ProviderConfigField,
|
||||
} from "@cline/shared";
|
||||
import { GENERATED_PROVIDER_MODELS } from "../catalog/catalog.generated";
|
||||
import { getGeneratedModelsForProvider } from "../catalog/catalog.generated-access";
|
||||
import {
|
||||
isCanonicalModelIdForAliasRules,
|
||||
@@ -307,7 +308,13 @@ function generatedModels(providerId: string): Record<string, ModelInfo> {
|
||||
}
|
||||
|
||||
function firstGeneratedModelId(providerId: string): string {
|
||||
const generatedModelList = Object.keys(generatedModels(providerId));
|
||||
// Use the catalog's authored order, not release-date order. The cline-pass
|
||||
// block mirrors the recommended-models endpoint, which lists the intended
|
||||
// default subscription model first — the newest model is not necessarily a
|
||||
// safe default.
|
||||
const generatedModelList = Object.keys(
|
||||
GENERATED_PROVIDER_MODELS.providers[providerId] ?? {},
|
||||
);
|
||||
if (!generatedModelList.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
+102
-3
@@ -1,11 +1,12 @@
|
||||
import { accessSync, constants as fsConstants } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import type { GatewayResolvedProviderConfig } from "@cline/shared";
|
||||
// Keep this import static so the VS Code extension bundle includes the SAP
|
||||
// provider. Hiding it behind a computed dynamic import leaves the published
|
||||
// extension trying to load @jerome-benoit/sap-ai-provider from node_modules at
|
||||
// runtime, but VSIX packaging uses the bundled extension output.
|
||||
import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider";
|
||||
import { createClaudeCode } from "ai-sdk-provider-claude-code";
|
||||
import { createCodexExec } from "ai-sdk-provider-codex-cli";
|
||||
import { createDifyProvider } from "dify-ai-provider";
|
||||
import { resolveApiKey } from "../http";
|
||||
import type { ProviderFactoryResult } from "./types";
|
||||
@@ -24,10 +25,94 @@ function readOptions(
|
||||
return (config.options as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
function findExecutableOnPath(name: string): string | undefined {
|
||||
const extensions =
|
||||
process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(dir, `${name}${ext}`);
|
||||
try {
|
||||
accessSync(candidate, fsConstants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// not here; keep looking
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The agent SDK spawns a `claude` executable shipped in per-platform optional
|
||||
// packages (@anthropic-ai/claude-agent-sdk-<platform>-<arch>[-musl]). Those
|
||||
// are no longer installed by default (~250MB), so resolve an explicit path:
|
||||
// the bundled platform binary when present, otherwise a user-installed
|
||||
// Claude Code from PATH. The SDK's own resolution cannot be relied on here:
|
||||
// inside a Bun-compiled binary it anchors on the virtual bunfs, where
|
||||
// node_modules lookups never see packages on disk.
|
||||
function resolveClaudeExecutable(): string | undefined {
|
||||
const suffixes =
|
||||
process.platform === "linux"
|
||||
? [
|
||||
`${process.platform}-${process.arch}`,
|
||||
`${process.platform}-${process.arch}-musl`,
|
||||
]
|
||||
: [`${process.platform}-${process.arch}`];
|
||||
const executableName = process.platform === "win32" ? "claude.exe" : "claude";
|
||||
// Anchor on the real executable location first so resolution works from
|
||||
// compiled binaries; fall back to this module's location for plain node.
|
||||
const anchors = [
|
||||
join(dirname(process.execPath), "noop.js"),
|
||||
import.meta.url,
|
||||
];
|
||||
for (const anchor of anchors) {
|
||||
for (const suffix of suffixes) {
|
||||
try {
|
||||
const manifest = createRequire(anchor).resolve(
|
||||
`@anthropic-ai/claude-agent-sdk-${suffix}/package.json`,
|
||||
);
|
||||
const executable = join(dirname(manifest), executableName);
|
||||
accessSync(executable, fsConstants.X_OK);
|
||||
return executable;
|
||||
} catch {
|
||||
// keep looking
|
||||
}
|
||||
}
|
||||
}
|
||||
return findExecutableOnPath("claude");
|
||||
}
|
||||
|
||||
export async function createClaudeCodeProviderModule(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
): Promise<ProviderFactoryResult> {
|
||||
const provider = createClaudeCode(readOptions(config));
|
||||
// Dynamic import is intentional: ai-sdk-provider-claude-code is an
|
||||
// optional peer dependency so default installs skip its ~250MB
|
||||
// @anthropic-ai/claude-agent-sdk platform binary. It also runs
|
||||
// createClaudeCode() at module scope, so loading lazily contains that
|
||||
// side effect to actual Claude Code usage.
|
||||
let createClaudeCode: typeof import("ai-sdk-provider-claude-code").createClaudeCode;
|
||||
try {
|
||||
({ createClaudeCode } = await import("ai-sdk-provider-claude-code"));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"The Claude Code provider requires the optional 'ai-sdk-provider-claude-code' package. " +
|
||||
"Install it alongside @cline/llms to use this provider.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const options = readOptions(config);
|
||||
const defaultSettings =
|
||||
(options.defaultSettings as Record<string, unknown> | undefined) ?? {};
|
||||
if (defaultSettings.pathToClaudeCodeExecutable === undefined) {
|
||||
const executable = resolveClaudeExecutable();
|
||||
if (executable !== undefined) {
|
||||
options.defaultSettings = {
|
||||
...defaultSettings,
|
||||
pathToClaudeCodeExecutable: executable,
|
||||
};
|
||||
}
|
||||
}
|
||||
const provider = createClaudeCode(options);
|
||||
return {
|
||||
model: (modelId) => provider(modelId),
|
||||
};
|
||||
@@ -36,6 +121,20 @@ export async function createClaudeCodeProviderModule(
|
||||
export async function createOpenAICodexProviderModule(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
): Promise<ProviderFactoryResult> {
|
||||
// Dynamic import is intentional: ai-sdk-provider-codex-cli is an optional
|
||||
// peer dependency so default installs skip its ~105MB @openai/codex
|
||||
// optional dependency. The provider itself degrades gracefully when the
|
||||
// bundled binary is absent (npx -y @openai/codex, then `codex` on PATH).
|
||||
let createCodexExec: typeof import("ai-sdk-provider-codex-cli").createCodexExec;
|
||||
try {
|
||||
({ createCodexExec } = await import("ai-sdk-provider-codex-cli"));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"The OpenAI Codex provider requires the optional 'ai-sdk-provider-codex-cli' package. " +
|
||||
"Install it alongside @cline/llms to use this provider.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const provider = createCodexExec(readOptions(config));
|
||||
return {
|
||||
model: (modelId) => provider(modelId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/sdk",
|
||||
"description": "Cline SDK - user-facing alias for @cline/core",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"description": "Shared utilities, types, and schemas for Cline packages",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -681,6 +681,14 @@ export interface AgentConfig {
|
||||
baseUrl?: string;
|
||||
/** Additional headers for API requests */
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Called when a run fails with an auth-like provider error (e.g. an OAuth
|
||||
* access token that expired mid-run). Hosts refresh credentials and push
|
||||
* the new key into the runtime via `updateConnection`; returning `true`
|
||||
* makes the runtime retry the failed run once with the refreshed
|
||||
* connection.
|
||||
*/
|
||||
onAuthError?: () => Promise<boolean>;
|
||||
/** Optional provider model catalog overrides */
|
||||
knownModels?: Record<string, ModelInfo>;
|
||||
/** Optional pre-resolved provider configuration (includes provider-specific fields like aws/gcp). */
|
||||
|
||||
@@ -220,7 +220,12 @@ export {
|
||||
} from "./parse/json";
|
||||
export { decodeJwtPayload } from "./parse/jwt";
|
||||
export { type OmitUndefinedValues, omitUndefinedValues } from "./parse/object";
|
||||
export { getDefaultShell, getShellArgs } from "./parse/shell";
|
||||
export {
|
||||
getDefaultShell,
|
||||
getShellArgs,
|
||||
getShellKind,
|
||||
type ShellKind,
|
||||
} from "./parse/shell";
|
||||
export {
|
||||
maskSecret,
|
||||
sanitizeFileName,
|
||||
|
||||
@@ -234,7 +234,12 @@ export {
|
||||
} from "./parse/json";
|
||||
export { decodeJwtPayload } from "./parse/jwt";
|
||||
export { type OmitUndefinedValues, omitUndefinedValues } from "./parse/object";
|
||||
export { getDefaultShell, getShellArgs } from "./parse/shell";
|
||||
export {
|
||||
getDefaultShell,
|
||||
getShellArgs,
|
||||
getShellKind,
|
||||
type ShellKind,
|
||||
} from "./parse/shell";
|
||||
export {
|
||||
maskSecret,
|
||||
sanitizeFileName,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDefaultShell, getShellArgs } from "./shell";
|
||||
import { getDefaultShell, getShellArgs, getShellKind } from "./shell";
|
||||
|
||||
describe("shell helpers", () => {
|
||||
it("selects PowerShell on Windows and bash elsewhere", () => {
|
||||
@@ -56,4 +56,24 @@ describe("shell helpers", () => {
|
||||
"echo hi",
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies shells into kinds consistent with their spawn args", () => {
|
||||
expect(getShellKind("powershell")).toBe("powershell");
|
||||
expect(getShellKind("C:\\Program Files\\PowerShell\\7\\pwsh.exe")).toBe(
|
||||
"powershell",
|
||||
);
|
||||
expect(
|
||||
getShellKind(
|
||||
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
),
|
||||
).toBe("powershell");
|
||||
expect(getShellKind("cmd.exe")).toBe("cmd");
|
||||
expect(getShellKind("C:\\Windows\\System32\\cmd.exe")).toBe("cmd");
|
||||
expect(getShellKind("C:\\Windows\\System32\\wsl.exe")).toBe("wsl");
|
||||
expect(getShellKind("/bin/bash")).toBe("posix");
|
||||
expect(getShellKind("/bin/zsh")).toBe("posix");
|
||||
expect(getShellKind("C:\\Program Files\\Git\\bin\\bash.exe")).toBe(
|
||||
"posix",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,21 @@ export function getDefaultShell(platform: string): string {
|
||||
return platform === "win32" ? "powershell" : "/bin/bash";
|
||||
}
|
||||
|
||||
export function getShellArgs(shell: string, command: string): string[] {
|
||||
/**
|
||||
* Shell families that differ in invocation flags and command syntax.
|
||||
* "wsl" is the wsl.exe launcher (which runs bash in the default distro);
|
||||
* "posix" covers bash/zsh/sh and other `-c`-style shells.
|
||||
*/
|
||||
export type ShellKind = "powershell" | "cmd" | "wsl" | "posix";
|
||||
|
||||
/**
|
||||
* Classify a shell executable (name or full path) into its family.
|
||||
*
|
||||
* This is the single classification used both for building spawn arguments
|
||||
* (getShellArgs) and for shell-specific prompting, so the syntax the model is
|
||||
* told to use always matches the syntax the executor actually accepts.
|
||||
*/
|
||||
export function getShellKind(shell: string): ShellKind {
|
||||
const shellName = normalizeShellName(shell);
|
||||
|
||||
if (
|
||||
@@ -21,20 +35,33 @@ export function getShellArgs(shell: string, command: string): string[] {
|
||||
shellName === "pwsh" ||
|
||||
shellName === "pwsh.exe"
|
||||
) {
|
||||
return ["-NoProfile", "-NonInteractive", "-Command", command];
|
||||
return "powershell";
|
||||
}
|
||||
|
||||
if (shellName === "cmd" || shellName === "cmd.exe") {
|
||||
return ["/d", "/s", "/c", command];
|
||||
return "cmd";
|
||||
}
|
||||
|
||||
// wsl.exe is the Windows launcher for the default WSL distro, not a shell
|
||||
// itself. Run the command through the guest's bash so operators like `|`
|
||||
// and `;` are handled by bash rather than treated as wsl.exe arguments.
|
||||
// wsl.exe translates the Windows cwd to its /mnt mount automatically.
|
||||
if (shellName === "wsl" || shellName === "wsl.exe") {
|
||||
return ["bash", "-c", command];
|
||||
return "wsl";
|
||||
}
|
||||
|
||||
return ["-c", command];
|
||||
return "posix";
|
||||
}
|
||||
|
||||
export function getShellArgs(shell: string, command: string): string[] {
|
||||
switch (getShellKind(shell)) {
|
||||
case "powershell":
|
||||
return ["-NoProfile", "-NonInteractive", "-Command", command];
|
||||
case "cmd":
|
||||
return ["/d", "/s", "/c", command];
|
||||
// wsl.exe is the Windows launcher for the default WSL distro, not a shell
|
||||
// itself. Run the command through the guest's bash so operators like `|`
|
||||
// and `;` are handled by bash rather than treated as wsl.exe arguments.
|
||||
// wsl.exe translates the Windows cwd to its /mnt mount automatically.
|
||||
case "wsl":
|
||||
return ["bash", "-c", command];
|
||||
case "posix":
|
||||
return ["-c", command];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export const AUTH_ERROR_PATTERNS = [
|
||||
* Returns `true` when `error` looks like an authentication failure.
|
||||
*/
|
||||
export function isLikelyAuthError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error ? error.message.toLowerCase() : String(error);
|
||||
const message = (
|
||||
error instanceof Error ? error.message : String(error)
|
||||
).toLowerCase();
|
||||
return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
|
||||
core: {
|
||||
allowedHosts: ["localhost", "127.0.0.1"],
|
||||
},
|
||||
framework: "@storybook/react-vite",
|
||||
async viteFinal(viteConfig) {
|
||||
viteConfig.plugins = [...(viteConfig.plugins ?? []), tailwindcss()];
|
||||
return viteConfig;
|
||||
},
|
||||
typescript: {
|
||||
check: true,
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,22 @@
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "../theme/index.css";
|
||||
@import "../components/agent-chat/agent-chat.css";
|
||||
|
||||
@source "../components";
|
||||
@source "../stories";
|
||||
@source ".";
|
||||
|
||||
html,
|
||||
body,
|
||||
#storybook-root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.cline-storybook-surface {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Decorator, Preview } from "@storybook/react-vite";
|
||||
import "./preview.css";
|
||||
|
||||
const withClineTheme: Decorator = (Story, context) => {
|
||||
const isDark = context.globals.theme === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
return (
|
||||
<div className="cline-storybook-surface">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [withClineTheme],
|
||||
parameters: {
|
||||
backgrounds: { disable: true },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
layout: "fullscreen",
|
||||
viewport: {
|
||||
viewports: {
|
||||
chatPanel: {
|
||||
name: "Chat panel",
|
||||
styles: { height: "800px", width: "700px" },
|
||||
type: "desktop",
|
||||
},
|
||||
mobile: {
|
||||
name: "Mobile",
|
||||
styles: { height: "844px", width: "390px" },
|
||||
type: "mobile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: "Cline color theme",
|
||||
defaultValue: "dark",
|
||||
toolbar: {
|
||||
dynamicTitle: true,
|
||||
icon: "circlehollow",
|
||||
items: [
|
||||
{ title: "Light", value: "light" },
|
||||
{ title: "Dark", value: "dark" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,497 @@
|
||||
# `@cline/ui` adoption primer
|
||||
|
||||
This guide is for Cline engineering teams that want a web application to share
|
||||
the Cline visual language and agent-chat presentation without copying desktop
|
||||
styles or adopting desktop product structure.
|
||||
|
||||
## The short version
|
||||
|
||||
`@cline/ui` has two opt-in layers:
|
||||
|
||||
1. A shared CSS theme built around standard shadcn/Tailwind semantic names.
|
||||
2. Reusable React presentation primitives for common agent-chat interfaces.
|
||||
|
||||
The theme provides:
|
||||
|
||||
- Light and dark semantic colors
|
||||
- Standard shadcn token names
|
||||
- Typography families, sizes, weights, line heights, and letter spacing
|
||||
- Borders, radii, cards, navigation, sidebar, and chart colors
|
||||
- Selection and scrollbar values
|
||||
- A small brand palette for artwork
|
||||
- Tailwind v4 mappings
|
||||
- Optional global, interaction, and Markdown styles
|
||||
|
||||
The first component surface provides:
|
||||
|
||||
- Sticky agent-conversation structure and a scroll-to-latest affordance
|
||||
- User, assistant, system, status, and error message presentation
|
||||
- Message actions with accessible labels and focus behavior
|
||||
- Controlled or uncontrolled reasoning disclosures
|
||||
- Static or expandable tool activity with running, success, and error states
|
||||
- Empty-conversation presentation
|
||||
|
||||
Each application continues to own:
|
||||
|
||||
- Runtime message and tool schemas
|
||||
- Session, provider, transport, streaming, and persistence behavior
|
||||
- Markdown rendering and external-link/image policy
|
||||
- Approval and follow-up-question orchestration
|
||||
- Checkpoint, fork, clipboard, and toast behavior
|
||||
- Page layouts, navigation, and product workflows
|
||||
- Font-file loading and framework integration
|
||||
- Product-specific animation and deliberate visual overrides
|
||||
|
||||
This boundary gives Cline products a shared visual and interaction language
|
||||
without turning `@cline/ui` into a second agent runtime.
|
||||
|
||||
## Current status
|
||||
|
||||
`@cline/ui` is configured for public npm publication with its own version and
|
||||
manual release workflow. Check availability with `npm view @cline/ui version`;
|
||||
an `E404` means the first release is still pending. The API is pre-stable, so
|
||||
production consumers should pin exact versions and review compatibility notes
|
||||
when updating.
|
||||
|
||||
Desktop is the first production-shaped consumer of both the theme and shared
|
||||
chat primitives. Storybook is the reference catalog for isolated component
|
||||
states. Hub and other agent interfaces are candidates for the next adoption
|
||||
pass once their runtime and Markdown adapters are mapped explicitly.
|
||||
|
||||
## Choose an adoption level
|
||||
|
||||
| Goal | Import | Tailwind required | React required |
|
||||
| --- | --- | --- | --- |
|
||||
| Use only light/dark CSS variables | `@cline/ui/theme/tokens.css` | No | No |
|
||||
| Use tokens through Tailwind utilities | `tokens.css` then `theme.css` | Tailwind v4 | No |
|
||||
| Use the complete theme and shared base behavior | `@cline/ui/theme/index.css` | Tailwind v4 | No |
|
||||
| Compose shared agent-chat presentation | `@cline/ui/components/agent-chat` plus its CSS | No, if tokens are mapped in plain CSS | React 18.3 or 19 |
|
||||
|
||||
The package exports `base.css` separately for consumers that want its global,
|
||||
Markdown, scrollbar, selection, cursor, and native `color-scheme` behavior.
|
||||
|
||||
There is no root JavaScript export and no `@cline/ui/theme` shorthand. Use the
|
||||
explicit paths documented here so dependencies remain visible.
|
||||
|
||||
## Install inside the Cline monorepo
|
||||
|
||||
Add the workspace dependency:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@cline/ui": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run the repository's normal package installation workflow after updating the
|
||||
manifest and lockfile.
|
||||
|
||||
## Install from npm in another repository
|
||||
|
||||
After the initial release is available, install the latest production UI
|
||||
release. The `--exact` flag records the resolved version instead of a range:
|
||||
|
||||
```bash
|
||||
bun add --exact @cline/ui
|
||||
```
|
||||
|
||||
The package is ESM. Its React entry point targets browser applications. Install
|
||||
only the prerequisites for the layer being adopted:
|
||||
|
||||
```bash
|
||||
# Required only for agent-chat components
|
||||
bun add react@^19 react-dom@^19
|
||||
|
||||
# Required for the documented Tailwind-backed theme and Cline fonts
|
||||
bun add @fontsource-variable/schibsted-grotesk @fontsource/azeret-mono
|
||||
bun add --dev tailwindcss
|
||||
```
|
||||
|
||||
Applications already on React 18.3 can retain that compatible version.
|
||||
Tokens-only consumers do not need React or Tailwind.
|
||||
|
||||
Commit the consuming repository's lockfile so builds continue using the same
|
||||
resolved version. Use the package manager's update command when the team
|
||||
intentionally wants to move to a newer release:
|
||||
|
||||
```bash
|
||||
bun update @cline/ui
|
||||
```
|
||||
|
||||
For deliberate previews, UI releases can publish an unstable `next` npm tag:
|
||||
|
||||
```bash
|
||||
bun add --exact @cline/ui@next
|
||||
```
|
||||
|
||||
Do not use `next` for production applications. UI versions move independently
|
||||
from the runtime SDK packages.
|
||||
|
||||
## Option 1: complete Tailwind v4 theme
|
||||
|
||||
Import fonts and Tailwind before the complete theme:
|
||||
|
||||
```css
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
```
|
||||
|
||||
This supplies:
|
||||
|
||||
- Framework-neutral token values
|
||||
- Tailwind semantic mappings and dark variant
|
||||
- Global typography and body styles
|
||||
- Markdown and code-block styling
|
||||
- Scrollbar and selection styling
|
||||
- Consistent pointer affordances
|
||||
- Native light/dark `color-scheme`
|
||||
|
||||
Application-specific CSS should follow these imports.
|
||||
|
||||
## Option 2: Tailwind mappings without base styles
|
||||
|
||||
Use this when the application wants the shared tokens and utilities but already
|
||||
owns document, Markdown, scrollbar, or cursor behavior:
|
||||
|
||||
```css
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@import "@cline/ui/theme/theme.css";
|
||||
```
|
||||
|
||||
If the application later opts into shared base behavior, import
|
||||
`@cline/ui/theme/base.css` after `theme.css`.
|
||||
|
||||
## Option 3: framework-neutral tokens
|
||||
|
||||
Applications without Tailwind can import only the variables:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
```
|
||||
|
||||
Token-only consumers must provide:
|
||||
|
||||
- Font files
|
||||
- Resets and document defaults
|
||||
- Native `color-scheme`, if desired
|
||||
- Their own mapping from CSS variables to framework utilities
|
||||
- Their own dark-mode class activation
|
||||
|
||||
For native controls that should follow the selected theme:
|
||||
|
||||
```css
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
```
|
||||
|
||||
## Add the agent-chat components
|
||||
|
||||
With the complete Tailwind theme, import the component styles afterward:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
```
|
||||
|
||||
Without Tailwind, import the framework-neutral tokens and component styles,
|
||||
then apply the shared font family at an app or chat root (tokens define font
|
||||
values but do not apply document typography):
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
.agent-chat-root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
```
|
||||
|
||||
Then compose the presentation around the consuming application's own data:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
type AgentMessageRole,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageActions,
|
||||
MessageAction,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityContent,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
|
||||
type ProductMessage = {
|
||||
id: string;
|
||||
role: "human" | "agent" | "system" | "error";
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
const roleMap: Record<ProductMessage["role"], AgentMessageRole> = {
|
||||
human: "user",
|
||||
agent: "assistant",
|
||||
system: "system",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
type AgentTranscriptProps = {
|
||||
conversationId: string;
|
||||
messages: ProductMessage[];
|
||||
onCopy: (message: ProductMessage) => void;
|
||||
renderMarkdown: (content: string) => ReactNode;
|
||||
};
|
||||
|
||||
export function AgentTranscript({
|
||||
conversationId,
|
||||
messages,
|
||||
onCopy,
|
||||
renderMarkdown,
|
||||
}: AgentTranscriptProps) {
|
||||
return (
|
||||
<Conversation
|
||||
className="agent-chat-root"
|
||||
key={conversationId}
|
||||
style={{ height: "32rem" }}
|
||||
>
|
||||
<ConversationViewport aria-label="Agent conversation">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<Message from={roleMap[message.role]} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.reasoning ? (
|
||||
<Reasoning isStreaming={message.isStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{renderMarkdown(message.reasoning)}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
) : null}
|
||||
|
||||
{renderMarkdown(message.content)}
|
||||
</MessageContent>
|
||||
|
||||
<MessageActions>
|
||||
<MessageAction label="Copy message" onClick={() => onCopy(message)}>
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</Message>
|
||||
))}
|
||||
|
||||
<ToolActivity expandable>
|
||||
<ToolActivityTrigger
|
||||
label="Edited 2 files"
|
||||
additions={24}
|
||||
deletions={8}
|
||||
status="success"
|
||||
/>
|
||||
<ToolActivityContent>Normalized tool details</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The explicit height keeps this standalone example scrollable. In a real shell,
|
||||
an equivalent bounded flex layout works too: every ancestor in the height chain
|
||||
must allow shrinking (commonly `min-height: 0`) and the conversation must fill
|
||||
the available height.
|
||||
|
||||
The example intentionally injects a consumer-owned `renderMarkdown`. Different
|
||||
products currently have different Streamdown plugins, syntax-highlighting
|
||||
budgets, link-confirmation behavior, and image policies. The React `key` resets
|
||||
conversation-local state when the active session changes. The shared package
|
||||
standardizes the surrounding presentation without silently changing those
|
||||
security and product decisions.
|
||||
|
||||
Map runtime roles and tool states at the consumer boundary. Do not make the UI
|
||||
package depend on `@cline/core`, the Vercel AI SDK, desktop schemas, or transport
|
||||
events.
|
||||
|
||||
## Explore components in Storybook
|
||||
|
||||
From the Cline repository root:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook
|
||||
```
|
||||
|
||||
Open `http://localhost:6006`. The toolbar switches light/dark mode and offers
|
||||
representative chat and mobile viewports. Stories cover:
|
||||
|
||||
- Theme colors, typography, radii, and controls
|
||||
- Complete and empty conversations
|
||||
- User, assistant, and error messages
|
||||
- Collapsed, expanded, and streaming reasoning
|
||||
- Pending, running, successful, and failed tool activity
|
||||
- Expandable and static tool summaries
|
||||
|
||||
In the repository's agent sandbox, bind to a forwarded host and unused port:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook -- --host 0.0.0.0 --port 3490 --exact-port
|
||||
```
|
||||
|
||||
Build the production Storybook bundle with:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui build-storybook
|
||||
```
|
||||
|
||||
Storybook is the isolated component reference. Real application builds remain
|
||||
the integration test for runtime adapters and product CSS.
|
||||
|
||||
The catalog currently runs from a Cline monorepo checkout. Story sources and
|
||||
configuration are not included in the npm package, and the catalog is not
|
||||
hosted yet.
|
||||
|
||||
## Token usage
|
||||
|
||||
Product components should use semantic tokens:
|
||||
|
||||
```css
|
||||
.card {
|
||||
color: var(--card-foreground);
|
||||
background: var(--card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
}
|
||||
```
|
||||
|
||||
Use the small `--brand-*` palette and `--primary-emphasis` for branded artwork
|
||||
or deliberate emphasis. Normal controls should prefer semantic tokens so they
|
||||
continue to work across light, dark, and future theme layers.
|
||||
|
||||
## Product overrides
|
||||
|
||||
Import the package first, then override standard semantic values:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/index.css";
|
||||
|
||||
:root {
|
||||
--primary: /* product-specific value */;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--primary: /* dark product-specific value */;
|
||||
}
|
||||
```
|
||||
|
||||
Do not copy `tokens.css` or component CSS into the consuming application.
|
||||
Explicit overrides make product differences reviewable and allow future
|
||||
package upgrades.
|
||||
|
||||
## Consumer-owned behavior
|
||||
|
||||
Keep the following outside `@cline/ui`:
|
||||
|
||||
- Next, Tauri, VS Code, and runtime-specific behavior
|
||||
- `#__next`, viewport locking, and shell layout
|
||||
- Application routes and information architecture
|
||||
- Session, workspace, provider, and sidecar behavior
|
||||
- Runtime event normalization and persistence
|
||||
- Tool-name classification and raw tool-payload parsing
|
||||
- Approval and question request orchestration
|
||||
- Product-specific actions and animation
|
||||
- Components that have not been proven reusable by multiple products
|
||||
|
||||
The package should standardize repeated visual and interaction language, not
|
||||
erase product boundaries.
|
||||
|
||||
## Adoption checklist
|
||||
|
||||
- [ ] Choose `workspace:*` or a pinned npm version.
|
||||
- [ ] Commit the consuming project's lockfile.
|
||||
- [ ] Choose tokens-only, Tailwind mappings, or the complete theme.
|
||||
- [ ] Load the required font files.
|
||||
- [ ] Import files in the documented order.
|
||||
- [ ] Import `agent-chat.css` when using the React primitives.
|
||||
- [ ] Install React 18.3 or 19 when using the React primitives.
|
||||
- [ ] Map product message/tool models at the package boundary.
|
||||
- [ ] Use the stable conversation identifier as the `Conversation` React `key`.
|
||||
- [ ] Keep Markdown and link/image policy explicit in the consumer.
|
||||
- [ ] Confirm the application's `.dark` behavior.
|
||||
- [ ] Put deliberate overrides after package imports.
|
||||
- [ ] Build the application in development and production.
|
||||
- [ ] Compare representative screens in light and dark modes.
|
||||
- [ ] Exercise focus, hover, disabled, streaming, and error states.
|
||||
- [ ] Check the same states in Storybook.
|
||||
- [ ] Record required overrides and missing shared behavior.
|
||||
|
||||
## Contract and compatibility expectations
|
||||
|
||||
Until the package has a stable version, contract changes should:
|
||||
|
||||
- Include a compatibility note
|
||||
- Run the package build, typechecking, and tests
|
||||
- Build Storybook
|
||||
- Build every active consumer
|
||||
- Include light/dark visual evidence when values change
|
||||
- Avoid renaming standard shadcn/Tailwind variables
|
||||
- Keep `tokens.css` framework-neutral
|
||||
- Keep component props independent of product runtime schemas
|
||||
- Keep product-specific layout and orchestration out of the package
|
||||
|
||||
Removing or changing the meaning of a semantic token or component prop should
|
||||
eventually be treated as a breaking change. Additive tokens, props, and entry
|
||||
points can be introduced compatibly.
|
||||
|
||||
## Release and stability roadmap
|
||||
|
||||
The npm package solves cross-repository distribution. The remaining work is to
|
||||
validate and stabilize the public contract.
|
||||
|
||||
Recommended sequence:
|
||||
|
||||
1. Adopt the theme and chat primitives in a second production-shaped Cline app.
|
||||
2. Record where that app needs adapters or deliberate variations.
|
||||
3. Assign design and engineering owners.
|
||||
4. Define browser, React, Tailwind, compatibility, and deprecation policies.
|
||||
5. Add screenshot regression coverage for representative Storybook states.
|
||||
6. Expand clean-consumer fixtures as supported frameworks are proven.
|
||||
7. Define the compatibility point at which the API can be treated as stable.
|
||||
|
||||
Likely follow-up components should be driven by repeated needs. Approval cards,
|
||||
follow-up questions, attachments, and prompt composers are candidates, but their
|
||||
current product contracts should be compared before standardizing them.
|
||||
|
||||
## Useful references
|
||||
|
||||
- [Package README](./README.md)
|
||||
- [Agent-chat components](./components/agent-chat/index.tsx)
|
||||
- [Agent-chat styles](./components/agent-chat/agent-chat.css)
|
||||
- [Tokens](./theme/tokens.css)
|
||||
- [Tailwind mappings](./theme/theme.css)
|
||||
- [Optional base styles](./theme/base.css)
|
||||
- [Complete theme](./theme/index.css)
|
||||
- [Package manifest](./package.json)
|
||||
- [Desktop theme integration test (monorepo)](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/webview/styles/theme-integration.test.ts)
|
||||
+141
-31
@@ -1,23 +1,44 @@
|
||||
# `@cline/ui`
|
||||
|
||||
Shared, framework-independent UI foundations for Cline web products. The
|
||||
package is internal to this monorepo while the first consumers settle the
|
||||
contract; it is not part of the public SDK release yet.
|
||||
Shared visual foundations and reusable React presentation primitives for Cline
|
||||
web products. The package lets teams adopt the same semantic theme and agent
|
||||
chat language without adopting another product's routes, state, or runtime.
|
||||
|
||||
## Theme entry points
|
||||
The package is configured for public npm releases on its own version and
|
||||
release cycle. Its API is still pre-stable, so consumers should pin an exact
|
||||
version and review compatibility notes when updating. Check availability with
|
||||
`npm view @cline/ui version`; an `E404` means the first release is still pending.
|
||||
|
||||
| Import | Contents | Requires Tailwind |
|
||||
See the [adoption primer](./ADOPTION.md) for complete setup instructions,
|
||||
component examples, boundaries, and release status.
|
||||
|
||||
## Install
|
||||
|
||||
After the initial release is available:
|
||||
|
||||
```bash
|
||||
bun add --exact @cline/ui
|
||||
```
|
||||
|
||||
Use `@cline/ui@next` only for deliberate previews. Monorepo consumers use
|
||||
`"@cline/ui": "workspace:*"` instead.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Import | Contents | Runtime requirement |
|
||||
| --- | --- | --- |
|
||||
| `@cline/ui/theme/tokens.css` | Light/dark custom properties only; no native `color-scheme` policy | No |
|
||||
| `@cline/ui/theme/theme.css` | Tailwind v4 semantic mapping and dark variant | Yes |
|
||||
| `@cline/ui/theme/base.css` | Optional base, Markdown, scrollbar, selection, and cursor styles; import after tokens and theme | Yes |
|
||||
| `@cline/ui/theme/index.css` | Complete theme: tokens, Tailwind mapping, and base styles | Yes |
|
||||
| `@cline/ui/theme/tokens.css` | Light/dark custom properties only | CSS |
|
||||
| `@cline/ui/theme/theme.css` | Tailwind v4 semantic mapping and dark variant | Tailwind v4 |
|
||||
| `@cline/ui/theme/base.css` | Optional document, Markdown, scrollbar, selection, and cursor styles | Tailwind v4 |
|
||||
| `@cline/ui/theme/index.css` | Complete theme: tokens, Tailwind mapping, and base styles | Tailwind v4 |
|
||||
| `@cline/ui/components/agent-chat` | Conversation, message, reasoning, action, and tool-activity React primitives | React 18.3 or 19 |
|
||||
| `@cline/ui/components/agent-chat.css` | Framework-neutral styles for the agent-chat primitives | Theme tokens |
|
||||
|
||||
The token-only entry point has no React, Tailwind, font-package, or desktop
|
||||
runtime dependency. Apps provide Schibsted Grotesk and Azeret Mono themselves,
|
||||
which lets each bundler control font loading and asset emission.
|
||||
The token entry point has no React, Tailwind, font-package, or desktop runtime
|
||||
dependency. Apps provide Schibsted Grotesk and Azeret Mono themselves, which
|
||||
lets each bundler control font loading and asset emission.
|
||||
|
||||
## Usage
|
||||
## Theme usage
|
||||
|
||||
For a Tailwind v4 app, import framework and consumer dependencies first:
|
||||
|
||||
@@ -28,7 +49,7 @@ For a Tailwind v4 app, import framework and consumer dependencies first:
|
||||
@import "@cline/ui/theme/index.css";
|
||||
```
|
||||
|
||||
An app that only needs the framework-neutral values can import just:
|
||||
An app that only needs framework-neutral values can import:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@@ -36,27 +57,116 @@ An app that only needs the framework-neutral values can import just:
|
||||
|
||||
The theme follows the standard shadcn semantic contract (`--background`,
|
||||
`--foreground`, `--card`, `--primary`, `--border`, `--ring`, charts, and
|
||||
sidebar surfaces) and Tailwind theme names (`--font-sans`, `--font-mono`,
|
||||
`--font-weight-*`, and `--text-*`). This means shadcn components and normal
|
||||
Tailwind utilities inherit the Cline defaults without `cline-*` adapters.
|
||||
sidebar surfaces) and Tailwind theme names. This means shadcn components and
|
||||
normal Tailwind utilities inherit Cline defaults without custom adapters.
|
||||
|
||||
Brand artwork may use the small extension set (`--primary-emphasis` and the
|
||||
`--brand-*` palette). Product components should prefer semantic variables.
|
||||
`--brand-*` palette). Product controls should prefer semantic variables.
|
||||
|
||||
## Agent-chat usage
|
||||
|
||||
Agent-chat consumers must provide React 18.3 or 19. Install React in the
|
||||
consuming application if it is not already present:
|
||||
|
||||
```bash
|
||||
bun add react@^19 react-dom@^19
|
||||
```
|
||||
|
||||
Applications already on React 18.3 can retain that compatible version.
|
||||
|
||||
In the application's global CSS, import the component styles after at least the
|
||||
theme tokens:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
```
|
||||
|
||||
Then import the React components:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
```
|
||||
|
||||
`Conversation` owns sticky scrolling, `Message` owns role presentation,
|
||||
`Reasoning` and `ToolActivity` provide accessible disclosures, and the smaller
|
||||
action, empty-state, detail, and code primitives fill out common transcript
|
||||
states. Give each conversation a bounded height through an explicit height or
|
||||
a complete flex/min-height chain so its viewport can scroll.
|
||||
|
||||
These are presentation primitives, not an agent SDK. Consumers map their own
|
||||
message and tool schemas into the components and retain their own Markdown,
|
||||
transport, approvals, persistence, and product actions.
|
||||
|
||||
## Storybook
|
||||
|
||||
Run the interactive component catalog from the repository root:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook
|
||||
```
|
||||
|
||||
Then open `http://localhost:6006`. Build the static catalog with:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui build-storybook
|
||||
```
|
||||
|
||||
In the repository's agent sandbox, bind to a forwarded host and unused port:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook -- --host 0.0.0.0 --port 3490 --exact-port
|
||||
```
|
||||
|
||||
The catalog includes the theme foundations and representative agent-chat
|
||||
states in light, dark, desktop, and narrow viewports.
|
||||
|
||||
Storybook currently runs from a Cline monorepo checkout. It is not hosted or
|
||||
included in the npm package; deployment can be added once the catalog and
|
||||
ownership model settle.
|
||||
|
||||
## Layering and compatibility
|
||||
|
||||
- Import the Cline theme after Tailwind so its default typography values win.
|
||||
- Override `:root` or `.dark` after the package import for a deliberate product
|
||||
variation; do not rename the default contract.
|
||||
- `base.css` is optional because it includes opinionated Markdown and global
|
||||
interaction styles. When importing files individually, load `tokens.css`,
|
||||
then `theme.css`, then `base.css`. Token-only consumers do not receive resets
|
||||
or `color-scheme`; import the base layer or declare `color-scheme` locally so
|
||||
native controls follow the selected light/dark theme.
|
||||
- Shell-specific layout such as `#__next`, viewport locking, and app animation
|
||||
keyframes stays with each consumer.
|
||||
- Contract changes should include a compatibility note and a consumer build.
|
||||
- Import `agent-chat.css` after theme tokens.
|
||||
- Override `:root` or `.dark` after package imports for deliberate product
|
||||
variations; do not rename the default semantic contract.
|
||||
- `base.css` is optional because it contains opinionated Markdown and global
|
||||
interaction styles.
|
||||
- Shell layout, routes, provider/session state, and runtime behavior stay with
|
||||
each consumer.
|
||||
- Contract changes should include a compatibility note, package tests, a
|
||||
Storybook build, and at least one real consumer build.
|
||||
|
||||
Tailwind theme variables are CSS-first and designed to be shared through an
|
||||
imported stylesheet. See the
|
||||
[Tailwind theme variable documentation](https://tailwindcss.com/docs/theme#sharing-across-projects).
|
||||
## Releases
|
||||
|
||||
The standalone `ui-publish.yml` workflow validates the package and publishes
|
||||
only after a manual dispatch from `main`. Production releases use the npm
|
||||
`latest` tag; deliberate previews use `next`. UI releases do not trigger the
|
||||
SDK release, GitHub releases, or Slack announcements.
|
||||
|
||||
Maintainers use the repository's `publish-ui` skill for the initial bootstrap
|
||||
and later releases.
|
||||
|
||||
The install command above pins the resolved release. Commit the consumer
|
||||
lockfile and update deliberately. The package is ESM and its React components
|
||||
target browser applications. A complete Tailwind theme also requires Tailwind
|
||||
v4 and the two font packages shown above.
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
@layer components {
|
||||
.cline-chat-conversation {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-conversation-viewport {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cline-chat-conversation-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 16rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state h3,
|
||||
.cline-chat-empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state h3 {
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cline-chat-empty-state p {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cline-chat-scroll-button {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
background: var(--secondary);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
|
||||
color: var(--secondary-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cline-chat-scroll-button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="user"] {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.cline-chat-message-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="user"] > .cline-chat-message-content {
|
||||
max-width: 85%;
|
||||
padding: 0.5rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: var(--card);
|
||||
color: color-mix(in oklab, var(--foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="error"] > .cline-chat-message-content {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid color-mix(in oklab, var(--destructive) 40%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in oklab, var(--destructive) 10%, transparent);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="system"] > .cline-chat-message-content,
|
||||
.cline-chat-message[data-role="status"] > .cline-chat-message-content {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message-actions {
|
||||
display: flex;
|
||||
min-height: 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.cline-chat-message:hover > .cline-chat-message-actions,
|
||||
.cline-chat-message:focus-within > .cline-chat-message-actions,
|
||||
.cline-chat-message-actions[data-visible="true"] {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cline-chat-message-action {
|
||||
display: inline-flex;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cline-chat-message-action:hover {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cline-chat-reasoning {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-trigger,
|
||||
.cline-chat-tool-trigger {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in oklab, var(--foreground) 70%, transparent);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.cline-chat-reasoning-trigger,
|
||||
button.cline-chat-tool-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.cline-chat-reasoning-trigger:disabled,
|
||||
button.cline-chat-tool-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-trigger:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-status {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.cline-chat-disclosure-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
[aria-expanded="true"] > .cline-chat-disclosure-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in oklab, var(--muted) 30%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.625;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cline-chat-tool {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.cline-chat-tool-trigger {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.cline-chat-tool-trigger[data-status="error"] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.cline-chat-tool-trigger[data-status="pending"] {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
button.cline-chat-tool-trigger:hover {
|
||||
color: color-mix(in oklab, var(--primary) 80%, transparent);
|
||||
}
|
||||
|
||||
button.cline-chat-tool-trigger[data-status="error"]:hover {
|
||||
color: color-mix(in oklab, var(--destructive) 80%, transparent);
|
||||
}
|
||||
|
||||
.cline-chat-tool-icon {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.cline-chat-tool-label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cline-chat-tool-diff {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.cline-chat-tool-diff [data-diff="additions"] {
|
||||
color: var(--chart-2);
|
||||
}
|
||||
|
||||
.cline-chat-tool-diff [data-diff="deletions"] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.cline-chat-tool-progress {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 9999px;
|
||||
animation: cline-chat-spin 800ms linear infinite;
|
||||
}
|
||||
|
||||
.cline-chat-tool-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 0.375rem;
|
||||
padding-left: 2rem;
|
||||
overflow-x: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cline-chat-tool-details {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cline-chat-tool-code {
|
||||
max-width: 100%;
|
||||
max-height: 13rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: color-mix(in oklab, var(--background) 60%, transparent);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.625;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cline-chat-conversation-viewport:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cline-chat-scroll-button:focus-visible,
|
||||
.cline-chat-message-action:focus-visible,
|
||||
.cline-chat-reasoning-trigger:focus-visible,
|
||||
.cline-chat-tool-trigger:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.cline-chat-message[data-role="user"] > .cline-chat-message-content {
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-chat-message-actions,
|
||||
.cline-chat-disclosure-icon {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cline-chat-tool-progress {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.cline-chat-message-actions {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cline-chat-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
createContext,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type RefCallback,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const STICK_TO_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function classNames(...values: Array<string | undefined | false>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
type ConversationContextValue = {
|
||||
setContent: (element: HTMLDivElement | null) => void;
|
||||
setViewport: (element: HTMLDivElement | null) => void;
|
||||
showScrollButton: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
};
|
||||
|
||||
const ConversationContext = createContext<ConversationContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useConversation(): ConversationContextValue {
|
||||
const context = useContext(ConversationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Conversation components must be rendered inside Conversation",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ConversationProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Conversation = forwardRef<HTMLDivElement, ConversationProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
|
||||
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const shouldStickToBottom = useRef(true);
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const lastProgrammaticScrollTop = useRef(0);
|
||||
const programmaticScrollTimer = useRef<number | null>(null);
|
||||
|
||||
const clearProgrammaticScroll = useCallback(() => {
|
||||
if (programmaticScrollTimer.current !== null) {
|
||||
window.clearTimeout(programmaticScrollTimer.current);
|
||||
programmaticScrollTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateScrollPosition = useCallback(() => {
|
||||
if (!viewport) return;
|
||||
const distance =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
if (isProgrammaticScroll.current) {
|
||||
if (viewport.scrollTop + 1 < lastProgrammaticScrollTop.current) {
|
||||
isProgrammaticScroll.current = false;
|
||||
clearProgrammaticScroll();
|
||||
} else {
|
||||
lastProgrammaticScrollTop.current = viewport.scrollTop;
|
||||
shouldStickToBottom.current = true;
|
||||
setShowScrollButton(false);
|
||||
if (distance <= STICK_TO_BOTTOM_THRESHOLD_PX) {
|
||||
isProgrammaticScroll.current = false;
|
||||
clearProgrammaticScroll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
shouldStickToBottom.current = distance <= STICK_TO_BOTTOM_THRESHOLD_PX;
|
||||
setShowScrollButton(distance > SCROLL_BUTTON_THRESHOLD_PX);
|
||||
}, [clearProgrammaticScroll, viewport]);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "smooth") => {
|
||||
if (!viewport) return;
|
||||
clearProgrammaticScroll();
|
||||
const prefersReducedMotion =
|
||||
behavior === "smooth" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const effectiveBehavior = prefersReducedMotion ? "auto" : behavior;
|
||||
const isSmooth = effectiveBehavior === "smooth";
|
||||
isProgrammaticScroll.current = isSmooth;
|
||||
lastProgrammaticScrollTop.current = viewport.scrollTop;
|
||||
shouldStickToBottom.current = true;
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: effectiveBehavior,
|
||||
});
|
||||
setShowScrollButton(false);
|
||||
if (!isSmooth) return;
|
||||
programmaticScrollTimer.current = window.setTimeout(() => {
|
||||
isProgrammaticScroll.current = false;
|
||||
programmaticScrollTimer.current = null;
|
||||
updateScrollPosition();
|
||||
}, 1500);
|
||||
},
|
||||
[clearProgrammaticScroll, updateScrollPosition, viewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewport) return;
|
||||
updateScrollPosition();
|
||||
viewport.addEventListener("scroll", updateScrollPosition);
|
||||
const cancelProgrammaticScroll = () => {
|
||||
if (!isProgrammaticScroll.current) return;
|
||||
isProgrammaticScroll.current = false;
|
||||
clearProgrammaticScroll();
|
||||
updateScrollPosition();
|
||||
};
|
||||
viewport.addEventListener("touchstart", cancelProgrammaticScroll, {
|
||||
passive: true,
|
||||
});
|
||||
viewport.addEventListener("pointerdown", cancelProgrammaticScroll, {
|
||||
passive: true,
|
||||
});
|
||||
const cancelProgrammaticScrollOnKeydown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
[
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ",
|
||||
].includes(event.key)
|
||||
) {
|
||||
cancelProgrammaticScroll();
|
||||
}
|
||||
};
|
||||
viewport.addEventListener("keydown", cancelProgrammaticScrollOnKeydown);
|
||||
viewport.addEventListener("wheel", cancelProgrammaticScroll, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollPosition);
|
||||
viewport.removeEventListener("touchstart", cancelProgrammaticScroll);
|
||||
viewport.removeEventListener("pointerdown", cancelProgrammaticScroll);
|
||||
viewport.removeEventListener(
|
||||
"keydown",
|
||||
cancelProgrammaticScrollOnKeydown,
|
||||
);
|
||||
viewport.removeEventListener("wheel", cancelProgrammaticScroll);
|
||||
};
|
||||
}, [clearProgrammaticScroll, updateScrollPosition, viewport]);
|
||||
|
||||
useEffect(() => () => clearProgrammaticScroll(), [clearProgrammaticScroll]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!viewport || !content) return;
|
||||
scrollToBottom("auto");
|
||||
}, [content, scrollToBottom, viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!content || !viewport || typeof ResizeObserver === "undefined")
|
||||
return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (shouldStickToBottom.current) {
|
||||
scrollToBottom("auto");
|
||||
} else {
|
||||
updateScrollPosition();
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [content, scrollToBottom, updateScrollPosition, viewport]);
|
||||
|
||||
const value = useMemo<ConversationContextValue>(
|
||||
() => ({
|
||||
scrollToBottom,
|
||||
setContent,
|
||||
setViewport,
|
||||
showScrollButton,
|
||||
}),
|
||||
[scrollToBottom, showScrollButton],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
<div
|
||||
className={classNames("cline-chat-conversation", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Conversation.displayName = "Conversation";
|
||||
|
||||
export type ConversationViewportProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"role"
|
||||
>;
|
||||
|
||||
export const ConversationViewport = forwardRef<
|
||||
HTMLDivElement,
|
||||
ConversationViewportProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
"aria-label": ariaLabel = "Agent conversation",
|
||||
"aria-live": ariaLive = "polite",
|
||||
className,
|
||||
tabIndex = 0,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { setViewport } = useConversation();
|
||||
const ref = useCallback<RefCallback<HTMLDivElement>>(
|
||||
(element) => {
|
||||
setViewport(element);
|
||||
assignRef(forwardedRef, element);
|
||||
},
|
||||
[forwardedRef, setViewport],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={ariaLabel}
|
||||
aria-live={ariaLive}
|
||||
className={classNames("cline-chat-conversation-viewport", className)}
|
||||
ref={ref}
|
||||
role="log"
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ConversationViewport.displayName = "ConversationViewport";
|
||||
|
||||
export type ConversationContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ConversationContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
ConversationContentProps
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const { setContent } = useConversation();
|
||||
const ref = useCallback<RefCallback<HTMLDivElement>>(
|
||||
(element) => {
|
||||
setContent(element);
|
||||
assignRef(forwardedRef, element);
|
||||
},
|
||||
[forwardedRef, setContent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("cline-chat-conversation-content", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ConversationContent.displayName = "ConversationContent";
|
||||
|
||||
export type ConversationEmptyStateProps = HTMLAttributes<HTMLDivElement> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
children,
|
||||
className,
|
||||
description = "Start a conversation to see messages here.",
|
||||
icon,
|
||||
title = "No messages yet",
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div className={classNames("cline-chat-empty-state", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon ? (
|
||||
<div className="cline-chat-empty-state-icon">{icon}</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"type"
|
||||
>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
"aria-label": ariaLabel = "Scroll to latest message",
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { scrollToBottom, showScrollButton } = useConversation();
|
||||
if (!showScrollButton) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-label={ariaLabel}
|
||||
className={classNames("cline-chat-scroll-button", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) scrollToBottom();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{children ?? <ChevronDownIcon />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type AgentMessageRole =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "system"
|
||||
| "status"
|
||||
| "error";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: AgentMessageRole;
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-message", className)}
|
||||
data-role={from}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={classNames("cline-chat-message-content", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
visible = false,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-message-actions", className)}
|
||||
data-visible={visible || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageActionProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"type"
|
||||
> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: MessageActionProps) => (
|
||||
<button
|
||||
{...props}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={classNames("cline-chat-message-action", className)}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
|
||||
type DisclosureState = {
|
||||
isOpen: boolean;
|
||||
panelId: string;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type ReasoningContextValue = DisclosureState & {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
function useReasoning(): ReasoningContextValue {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be rendered inside Reasoning");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ReasoningProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const Reasoning = ({
|
||||
className,
|
||||
defaultOpen = false,
|
||||
isStreaming = false,
|
||||
onOpenChange,
|
||||
open,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const panelId = useId();
|
||||
const isOpen = open ?? internalOpen;
|
||||
const setIsOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (open === undefined) setInternalOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[onOpenChange, open],
|
||||
);
|
||||
const value = useMemo(
|
||||
() => ({ isOpen, isStreaming, panelId, setIsOpen }),
|
||||
[isOpen, isStreaming, panelId, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={value}>
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-reasoning", className)}
|
||||
data-streaming={isStreaming || undefined}
|
||||
/>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ReasoningTriggerProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"aria-controls" | "aria-expanded" | "type"
|
||||
> & {
|
||||
completeLabel?: string;
|
||||
streamingLabel?: string;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = ({
|
||||
children,
|
||||
className,
|
||||
completeLabel = "Thought process",
|
||||
onClick,
|
||||
streamingLabel = "Thinking",
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isOpen, isStreaming, panelId, setIsOpen } = useReasoning();
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
className={classNames("cline-chat-reasoning-trigger", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) setIsOpen(!isOpen);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon />
|
||||
<span>{isStreaming ? streamingLabel : completeLabel}</span>
|
||||
<span aria-live="polite" className="cline-chat-reasoning-status">
|
||||
{isStreaming ? "In progress" : "Complete"}
|
||||
</span>
|
||||
<ChevronDownIcon className="cline-chat-disclosure-icon" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ReasoningContentProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"hidden" | "id"
|
||||
>;
|
||||
|
||||
export const ReasoningContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ReasoningContentProps) => {
|
||||
const { isOpen, panelId } = useReasoning();
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-reasoning-content", className)}
|
||||
id={panelId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityStatus = "pending" | "running" | "success" | "error";
|
||||
|
||||
type ToolActivityContextValue = DisclosureState & {
|
||||
expandable: boolean;
|
||||
};
|
||||
|
||||
const ToolActivityContext = createContext<ToolActivityContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useToolActivity(): ToolActivityContextValue {
|
||||
const context = useContext(ToolActivityContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ToolActivity components must be rendered inside ToolActivity",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ToolActivityProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
expandable?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ToolActivity = ({
|
||||
className,
|
||||
defaultOpen = false,
|
||||
expandable = true,
|
||||
onOpenChange,
|
||||
open,
|
||||
...props
|
||||
}: ToolActivityProps) => {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const panelId = useId();
|
||||
const isOpen = expandable && (open ?? internalOpen);
|
||||
const setIsOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!expandable) return;
|
||||
if (open === undefined) setInternalOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[expandable, onOpenChange, open],
|
||||
);
|
||||
const value = useMemo(
|
||||
() => ({ expandable, isOpen, panelId, setIsOpen }),
|
||||
[expandable, isOpen, panelId, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActivityContext.Provider value={value}>
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-tool", className)}
|
||||
data-expandable={expandable || undefined}
|
||||
/>
|
||||
</ToolActivityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityTriggerProps = Omit<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
"aria-controls" | "aria-expanded"
|
||||
> & {
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
status?: ToolActivityStatus;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ToolActivityTrigger = ({
|
||||
additions,
|
||||
children,
|
||||
className,
|
||||
deletions,
|
||||
disabled = false,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
status = "success",
|
||||
...props
|
||||
}: ToolActivityTriggerProps) => {
|
||||
const { expandable, isOpen, panelId, setIsOpen } = useToolActivity();
|
||||
const content = children ?? (
|
||||
<>
|
||||
{icon ? <span className="cline-chat-tool-icon">{icon}</span> : null}
|
||||
<span className="cline-chat-tool-label">{label}</span>
|
||||
{additions !== undefined || deletions !== undefined ? (
|
||||
<span className="cline-chat-tool-diff">
|
||||
{additions !== undefined ? (
|
||||
<span data-diff="additions">+{additions}</span>
|
||||
) : null}{" "}
|
||||
{deletions !== undefined ? (
|
||||
<span data-diff="deletions">-{deletions}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{status === "running" || status === "pending" ? (
|
||||
<output aria-label={status} className="cline-chat-tool-progress" />
|
||||
) : null}
|
||||
{expandable ? (
|
||||
<ChevronDownIcon className="cline-chat-disclosure-icon" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const handleClick = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
onClick?.(event);
|
||||
if (expandable && !event.defaultPrevented) setIsOpen(!isOpen);
|
||||
};
|
||||
const triggerClassName = classNames("cline-chat-tool-trigger", className);
|
||||
|
||||
if (expandable) {
|
||||
return (
|
||||
<button
|
||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
className={triggerClassName}
|
||||
data-status={status}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(props as HTMLAttributes<HTMLDivElement>)}
|
||||
className={triggerClassName}
|
||||
data-status={status}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityContentProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"hidden" | "id"
|
||||
>;
|
||||
|
||||
export const ToolActivityContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ToolActivityContentProps) => {
|
||||
const { expandable, isOpen, panelId } = useToolActivity();
|
||||
if (!expandable || !isOpen) return null;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-tool-content", className)}
|
||||
id={panelId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityDetailsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ToolActivityDetails = ({
|
||||
className,
|
||||
...props
|
||||
}: ToolActivityDetailsProps) => (
|
||||
<div
|
||||
className={classNames("cline-chat-tool-details", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolActivityCodeProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const ToolActivityCode = ({
|
||||
className,
|
||||
...props
|
||||
}: ToolActivityCodeProps) => (
|
||||
<pre className={classNames("cline-chat-tool-code", className)} {...props} />
|
||||
);
|
||||
|
||||
function ChevronDownIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
>
|
||||
<path
|
||||
d="M9.5 4.5A3 3 0 0 0 4 6a3 3 0 0 0 .5 5.9A3.5 3.5 0 0 0 8 17h1.5m5-12.5A3 3 0 0 1 20 6a3 3 0 0 1-.5 5.9A3.5 3.5 0 0 1 16 17h-1.5M9.5 4.5V20m5-15.5V20M9.5 9H7m7.5 3H17m-7.5 4H7m7.5 1h2"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"name": "@cline/ui",
|
||||
"version": "0.0.0",
|
||||
"description": "Shared Cline web theme and UI foundations",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared Cline web theme and reusable agent UI components",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
"directory": "sdk/packages/ui"
|
||||
},
|
||||
"private": true,
|
||||
"private": false,
|
||||
"internal": true,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./components/agent-chat": {
|
||||
"types": "./dist/components/agent-chat/index.d.ts",
|
||||
"import": "./dist/components/agent-chat/index.js"
|
||||
},
|
||||
"./components/agent-chat.css": "./components/agent-chat/agent-chat.css",
|
||||
"./theme/index.css": "./theme/index.css",
|
||||
"./theme/tokens.css": "./theme/tokens.css",
|
||||
"./theme/theme.css": "./theme/theme.css",
|
||||
@@ -18,23 +26,63 @@
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"components/agent-chat/agent-chat.css",
|
||||
"components/agent-chat/index.tsx",
|
||||
"dist",
|
||||
"theme",
|
||||
"ADOPTION.md",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"./components/**/*.css",
|
||||
"./theme/*.css"
|
||||
],
|
||||
"keywords": [
|
||||
"cline",
|
||||
"ui",
|
||||
"design-system",
|
||||
"react",
|
||||
"tailwindcss",
|
||||
"agent-chat"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "bun scripts/validate-theme.ts",
|
||||
"build": "bun scripts/validate-theme.ts && bun tsc -p tsconfig.build.json",
|
||||
"prepack": "bun run build",
|
||||
"storybook": "storybook dev",
|
||||
"build-storybook": "storybook build --output-dir tmp/storybook-static",
|
||||
"test:package": "bun run build && bun scripts/smoke-package.ts",
|
||||
"typecheck": "bun tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.3.0 <20",
|
||||
"tailwindcss": ">=4.0.0 <5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"tailwindcss": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@storybook/addon-a11y": "^9.1.17",
|
||||
"@storybook/addon-docs": "^9.1.17",
|
||||
"@storybook/react-vite": "^9.1.6",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^9.1.17",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.11",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
const packageRoot = join(import.meta.dir, "..");
|
||||
const importCheck =
|
||||
'import { Conversation, Message } from "@cline/ui/components/agent-chat"; const css = import.meta.resolve("@cline/ui/components/agent-chat.css"); const tokens = import.meta.resolve("@cline/ui/theme/tokens.css"); if (!Conversation || !Message || !css || !tokens) process.exit(1);';
|
||||
|
||||
async function run(command: string[], cwd: string): Promise<void> {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd,
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode = await child.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`${command.join(" ")} exited with ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createConsumer(root: string): void {
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
`${JSON.stringify({ name: "cline-ui-smoke", private: true, type: "module" }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-ui-package-"));
|
||||
|
||||
try {
|
||||
let archive = process.argv[2] ? resolve(process.argv[2]) : undefined;
|
||||
if (!archive) {
|
||||
const packDirectory = join(temporaryRoot, "pack");
|
||||
mkdirSync(packDirectory, { recursive: true });
|
||||
await run(
|
||||
[
|
||||
process.execPath,
|
||||
"pm",
|
||||
"pack",
|
||||
"--ignore-scripts",
|
||||
"--destination",
|
||||
packDirectory,
|
||||
],
|
||||
packageRoot,
|
||||
);
|
||||
const archiveName = readdirSync(packDirectory).find((name) =>
|
||||
name.endsWith(".tgz"),
|
||||
);
|
||||
if (!archiveName) throw new Error("bun pm pack did not create an archive");
|
||||
archive = join(packDirectory, archiveName);
|
||||
}
|
||||
|
||||
const bunConsumer = join(temporaryRoot, "bun-consumer");
|
||||
createConsumer(bunConsumer);
|
||||
await run(
|
||||
[process.execPath, "add", "--ignore-scripts", archive, "react@19.2.4"],
|
||||
bunConsumer,
|
||||
);
|
||||
await run([process.execPath, "-e", importCheck], bunConsumer);
|
||||
|
||||
const npmConsumer = join(temporaryRoot, "npm-consumer");
|
||||
createConsumer(npmConsumer);
|
||||
await run(
|
||||
[
|
||||
"npm",
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
archive,
|
||||
"react@18.3.1",
|
||||
],
|
||||
npmConsumer,
|
||||
);
|
||||
await run(["node", "--input-type=module", "-e", importCheck], npmConsumer);
|
||||
console.log(
|
||||
`Verified packed ${basename(archive)} with Bun/React 19 and npm/Node/React 18`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "../components/agent-chat";
|
||||
|
||||
const meta: Meta<typeof Conversation> = {
|
||||
title: "Agent chat/Primitives",
|
||||
component: Conversation,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Composable presentation primitives for agent conversations. Products retain transport, schemas, Markdown policy, approvals, and tool-result normalization.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
function SearchIcon() {
|
||||
return <span aria-hidden="true">⌕</span>;
|
||||
}
|
||||
|
||||
function TerminalIcon() {
|
||||
return <span aria-hidden="true">›_</span>;
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return <span aria-hidden="true">✎</span>;
|
||||
}
|
||||
|
||||
function ChatFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-[680px] min-w-[320px] bg-background">
|
||||
<Conversation>
|
||||
<ConversationViewport aria-label="Example agent conversation">
|
||||
<ConversationContent className="mx-auto max-w-3xl p-6">
|
||||
{children}
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CompleteConversation = () => (
|
||||
<ChatFrame>
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
Can you find the settings screen and align it with our shared theme?
|
||||
</MessageContent>
|
||||
<MessageActions>
|
||||
<MessageAction label="Copy user message" title="Copy message">
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</Message>
|
||||
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Reasoning>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
I should inspect the existing navigation and map its surfaces to the
|
||||
semantic theme contract before changing layout.
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</MessageContent>
|
||||
|
||||
<ToolActivity expandable>
|
||||
<ToolActivityTrigger
|
||||
icon={<SearchIcon />}
|
||||
label="Explored 3 files"
|
||||
status="success"
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
<ToolActivityDetails>
|
||||
<div>settings-view.tsx</div>
|
||||
<div>agent-sidebar.tsx</div>
|
||||
<div>tokens.css</div>
|
||||
</ToolActivityDetails>
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
|
||||
<ToolActivity expandable>
|
||||
<ToolActivityTrigger
|
||||
additions={42}
|
||||
deletions={18}
|
||||
icon={<EditIcon />}
|
||||
label="Edited settings-view.tsx"
|
||||
status="success"
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
<ToolActivityCode>
|
||||
{"+ background: var(--background);\n- background: #111;"}
|
||||
</ToolActivityCode>
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
|
||||
<MessageContent>
|
||||
<p>
|
||||
Done. Settings now uses the shared background, card, border, and
|
||||
typography tokens in both light and dark modes.
|
||||
</p>
|
||||
<ul className="cline-markdown list-disc pl-5">
|
||||
<li>Aligned navigation surfaces</li>
|
||||
<li>Preserved product-specific settings behavior</li>
|
||||
<li>Verified keyboard focus states</li>
|
||||
</ul>
|
||||
</MessageContent>
|
||||
<MessageActions>
|
||||
<MessageAction label="Copy assistant message" title="Copy response">
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const Streaming = () => (
|
||||
<ChatFrame>
|
||||
<Message from="user">
|
||||
<MessageContent>Run the focused tests.</MessageContent>
|
||||
</Message>
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Reasoning defaultOpen isStreaming>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
I am checking the package build, component interactions, and the
|
||||
static Storybook output.
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</MessageContent>
|
||||
<ToolActivity expandable={false}>
|
||||
<ToolActivityTrigger
|
||||
icon={<TerminalIcon />}
|
||||
label="Running bun -F @cline/ui test"
|
||||
status="running"
|
||||
/>
|
||||
</ToolActivity>
|
||||
<MessageContent>All package tests are passing so far…</MessageContent>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const ToolStates = () => (
|
||||
<ChatFrame>
|
||||
{(
|
||||
[
|
||||
["Waiting to edit theme.css", "pending"],
|
||||
["Running component tests", "running"],
|
||||
["Updated 2 files", "success"],
|
||||
["Command failed with exit code 1", "error"],
|
||||
] as const
|
||||
).map(([label, status]) => (
|
||||
<ToolActivity expandable={status !== "pending"} key={status}>
|
||||
<ToolActivityTrigger
|
||||
icon={<TerminalIcon />}
|
||||
label={label}
|
||||
status={status}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
<ToolActivityCode>
|
||||
{status === "error"
|
||||
? "Error: expected --background token"
|
||||
: "@cline/ui theme contract is valid"}
|
||||
</ToolActivityCode>
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
))}
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const Empty = () => (
|
||||
<ChatFrame>
|
||||
<ConversationEmptyState
|
||||
description="Send a prompt to begin an agent session."
|
||||
icon={<span className="text-3xl">✦</span>}
|
||||
title="What should we build?"
|
||||
/>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const ErrorMessage = () => (
|
||||
<ChatFrame>
|
||||
<Message from="error">
|
||||
<MessageContent>
|
||||
The agent connection was interrupted. Your conversation is safe; retry
|
||||
when the connection is restored.
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const DisabledControls = () => (
|
||||
<ChatFrame>
|
||||
<Message from="assistant">
|
||||
<MessageContent>Actions stay readable when unavailable.</MessageContent>
|
||||
<MessageActions visible>
|
||||
<MessageAction disabled label="Copy message">
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
<Reasoning>
|
||||
<ReasoningTrigger disabled />
|
||||
<ReasoningContent>Unavailable reasoning</ReasoningContent>
|
||||
</Reasoning>
|
||||
<ToolActivity>
|
||||
<ToolActivityTrigger disabled label="Tool details unavailable" />
|
||||
<ToolActivityContent>Unavailable tool details</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
|
||||
const colors = [
|
||||
["Background", "--background"],
|
||||
["Foreground", "--foreground"],
|
||||
["Card", "--card"],
|
||||
["Primary", "--primary"],
|
||||
["Secondary", "--secondary"],
|
||||
["Muted", "--muted"],
|
||||
["Accent", "--accent"],
|
||||
["Destructive", "--destructive"],
|
||||
["Border", "--border"],
|
||||
] as const;
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Foundations/Theme",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The shared Cline semantic color, typography, radius, and interaction contract. Use the toolbar to compare light and dark modes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Overview = () => (
|
||||
<main className="mx-auto grid max-w-5xl gap-10 p-8">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm font-medium text-primary">@cline/ui</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">
|
||||
Cline visual foundations
|
||||
</h1>
|
||||
<p className="max-w-2xl text-base text-muted-foreground">
|
||||
Semantic values let products share a recognizable visual language while
|
||||
retaining their own layouts and workflows.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Semantic colors</h2>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{colors.map(([label, token]) => (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border bg-card"
|
||||
key={token}
|
||||
>
|
||||
<div
|
||||
className="h-20 border-b"
|
||||
style={{ background: `var(${token})` }}
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<code className="text-xs text-muted-foreground">{token}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4 rounded-xl border bg-card p-6">
|
||||
<h2 className="text-xl font-semibold">Typography</h2>
|
||||
<div className="space-y-3">
|
||||
<p className="text-3xl font-semibold">Schibsted Grotesk</p>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Readable product copy with a warm, technical character.
|
||||
</p>
|
||||
<code className="block rounded-md bg-muted p-3 font-mono text-sm">
|
||||
Azeret Mono · npm run build
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border bg-card p-6">
|
||||
<h2 className="text-xl font-semibold">Controls</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
type="button"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border bg-background px-4 py-2 text-sm font-medium"
|
||||
type="button"
|
||||
>
|
||||
Secondary
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
type="button"
|
||||
>
|
||||
Ghost
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
aria-label="Example input"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Ask Cline something..."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -0,0 +1,204 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityContent,
|
||||
ToolActivityTrigger,
|
||||
} from "../components/agent-chat";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function render(element: React.ReactNode) {
|
||||
await act(async () => root.render(element));
|
||||
}
|
||||
|
||||
describe("@cline/ui agent chat primitives", () => {
|
||||
it("marks message roles without requiring a runtime message schema", async () => {
|
||||
await render(
|
||||
<Message from="assistant">
|
||||
<MessageContent>Hello from Cline</MessageContent>
|
||||
</Message>,
|
||||
);
|
||||
|
||||
const message = container.querySelector(".cline-chat-message");
|
||||
expect(message?.getAttribute("data-role")).toBe("assistant");
|
||||
expect(message?.textContent).toContain("Hello from Cline");
|
||||
});
|
||||
|
||||
it("gives the scrollable conversation log accessible defaults", async () => {
|
||||
await render(
|
||||
<Conversation>
|
||||
<ConversationViewport>
|
||||
<ConversationContent />
|
||||
</ConversationViewport>
|
||||
</Conversation>,
|
||||
);
|
||||
|
||||
const viewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
);
|
||||
expect(viewport?.getAttribute("aria-label")).toBe("Agent conversation");
|
||||
expect(viewport?.getAttribute("role")).toBe("log");
|
||||
expect(viewport?.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("exposes an accessible reasoning disclosure", async () => {
|
||||
await render(
|
||||
<Reasoning>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>Inspect the shared contract</ReasoningContent>
|
||||
</Reasoning>,
|
||||
);
|
||||
|
||||
const trigger = container.querySelector("button");
|
||||
const panelId = trigger?.getAttribute("aria-controls");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.getElementById(panelId ?? "")).toBeNull();
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
|
||||
"Inspect the shared contract",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders non-expandable tool activity as static content", async () => {
|
||||
await render(
|
||||
<ToolActivity expandable={false}>
|
||||
<ToolActivityTrigger label="Explored workspace" />
|
||||
</ToolActivity>,
|
||||
);
|
||||
|
||||
const summary = container.querySelector(".cline-chat-tool-trigger");
|
||||
expect(summary?.tagName).toBe("DIV");
|
||||
expect(summary?.closest("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles expandable tool details", async () => {
|
||||
await render(
|
||||
<ToolActivity>
|
||||
<ToolActivityTrigger label="Edited 2 files" />
|
||||
<ToolActivityContent>theme.css</ToolActivityContent>
|
||||
</ToolActivity>,
|
||||
);
|
||||
|
||||
const trigger = container.querySelector("button");
|
||||
const panelId = trigger?.getAttribute("aria-controls");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.getElementById(panelId ?? "")).toBeNull();
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
|
||||
"theme.css",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a scroll-to-latest action after the reader moves away", async () => {
|
||||
await render(
|
||||
<Conversation>
|
||||
<ConversationViewport>
|
||||
<ConversationContent>Long conversation</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>,
|
||||
);
|
||||
|
||||
const viewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
) as HTMLDivElement;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperties(viewport, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, value: 0, writable: true },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
await act(async () => viewport.dispatchEvent(new Event("scroll")));
|
||||
const button = container.querySelector(
|
||||
'button[aria-label="Scroll to latest message"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => button.click());
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 500 });
|
||||
|
||||
viewport.scrollTop = 300;
|
||||
await act(async () => viewport.dispatchEvent(new Event("scroll")));
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).toBeNull();
|
||||
|
||||
viewport.scrollTop = 100;
|
||||
await act(async () => viewport.dispatchEvent(new Event("scroll")));
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resets conversation state when its React key changes", async () => {
|
||||
const transcript = (conversationKey: string) => (
|
||||
<Conversation key={conversationKey}>
|
||||
<ConversationViewport>
|
||||
<ConversationContent>
|
||||
Conversation {conversationKey}
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
await render(transcript("session-a"));
|
||||
|
||||
const firstViewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
) as HTMLDivElement;
|
||||
Object.defineProperties(firstViewport, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, value: 0, writable: true },
|
||||
});
|
||||
|
||||
await act(async () => firstViewport.dispatchEvent(new Event("scroll")));
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
await render(transcript("session-b"));
|
||||
|
||||
const nextViewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
);
|
||||
expect(nextViewport).not.toBe(firstViewport);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const manifestPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
||||
|
||||
describe("@cline/ui package", () => {
|
||||
it("is configured for standalone public npm releases", () => {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
||||
internal?: boolean;
|
||||
license?: string;
|
||||
private?: boolean;
|
||||
publishConfig?: { access?: string };
|
||||
version?: string;
|
||||
};
|
||||
|
||||
expect(manifest.private).toBe(false);
|
||||
expect(manifest.internal).toBe(true);
|
||||
expect(manifest.publishConfig?.access).toBe("public");
|
||||
expect(manifest.license).toBe("Apache-2.0");
|
||||
expect(manifest.version).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
||||
expect(manifest.version).not.toBe("0.0.0");
|
||||
});
|
||||
});
|
||||
@@ -213,6 +213,7 @@ describe("@cline/ui theme contract", () => {
|
||||
readFileSync(join(packageRoot, "package.json"), "utf8"),
|
||||
) as { exports?: Record<string, string> };
|
||||
for (const subpath of [
|
||||
"./components/agent-chat.css",
|
||||
"./theme/index.css",
|
||||
"./theme/tokens.css",
|
||||
"./theme/theme.css",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": false,
|
||||
"outDir": "dist/components",
|
||||
"rootDir": "components",
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": ["components/**/*.ts", "components/**/*.tsx"],
|
||||
"exclude": ["components/**/*.stories.ts", "components/**/*.stories.tsx"]
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"types": ["bun", "node"]
|
||||
"types": ["bun", "node", "react", "react-dom"]
|
||||
},
|
||||
"include": ["scripts/**/*.ts", "tests/**/*.ts"]
|
||||
"include": [
|
||||
".storybook/**/*.ts",
|
||||
".storybook/**/*.tsx",
|
||||
"components/**/*.ts",
|
||||
"components/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"stories/**/*.ts",
|
||||
"stories/**/*.tsx",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
include: ["tests/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user