Compare commits

...

15 Commits

Author SHA1 Message Date
Saoud Rizwan c2faf38d72 chore(cli): release v3.0.46 2026-07-18 23:14:41 -07:00
Saoud Rizwan 4dab17769c fix(cli): detect real insufficient_credits error from Cline API (#12394) 2026-07-18 23:11:46 -07:00
Saoud Rizwan 396032cd3b chore(cli): release v3.0.45 2026-07-18 21:03:37 -07:00
Saoud Rizwan f33ab3a872 chore(sdk): release v0.0.65 2026-07-18 20:46:28 -07:00
Saoud Rizwan 2ca8364ffc docs: add Kimi K3 to ClinePass model list and reference pricing (#12393) 2026-07-18 20:35:48 -07:00
Saoud Rizwan 2ef81be703 feat(llms): make Claude Code and Codex provider packages optional peers (#12379)
ai-sdk-provider-claude-code and ai-sdk-provider-codex-cli were hard
dependencies of @cline/llms, so every npm install of the cline CLI
pulled their native binaries (~250MB claude-agent-sdk platform binary,
~105MB @openai/codex) even for users who never select those providers.

Move both to optional peerDependencies (kept as devDependencies so
monorepo builds still bundle the JS) and load them via literal dynamic
imports in community.ts, mirroring the existing opencode-sdk pattern.

The Claude Code provider now resolves the claude executable explicitly:
bundled platform package when present, otherwise a user-installed
claude from PATH, passed via defaultSettings.pathToClaudeCodeExecutable.
The agent SDK's own resolution cannot be used from Bun-compiled
binaries because it anchors on the virtual bunfs where node_modules
lookups never see packages on disk. Codex already degrades gracefully
(npx -y @openai/codex, then codex on PATH).
2026-07-18 20:30:24 -07:00
Saoud Rizwan 359445ae0c fix(sdk): stop exposing the team spawn tool to teammates (#12371)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* fix(sdk): report errored teammate runs as failed instead of completed

Model-stream failures return results with finishReason 'error' rather
than throwing, so executeQueuedRun marked such runs 'completed' with
the error buried in resultSummary. Throw into the existing failure
path so the run reports status 'failed' (with run.error set and a
RunFailed event) and the retry machinery engages when maxRetries
allows.

* fix(sdk): stop exposing the team spawn tool to teammates

Spawning is lead-only, enforced at execution time, so teammates that
saw team_spawn_teammate in their toolset burned turns on 'Only the
lead agent can manage teammates.' rejections before falling back to
doing the work themselves.
2026-07-18 20:21:35 -07:00
Saoud Rizwan d9e2e9c76b fix(sdk): report errored teammate runs as failed instead of completed (#12370)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* fix(sdk): report errored teammate runs as failed instead of completed

Model-stream failures return results with finishReason 'error' rather
than throwing, so executeQueuedRun marked such runs 'completed' with
the error buried in resultSummary. Throw into the existing failure
path so the run reports status 'failed' (with run.error set and a
RunFailed event) and the retry machinery engages when maxRetries
allows.
2026-07-18 20:19:17 -07:00
Saoud Rizwan d859a86a6f fix(sdk): retry runs once after refreshing expired OAuth credentials (#12369)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* feat(telemetry): emit user.auth_run_retry when a run is retried after credential refresh

Addresses Greptile review on the auth-retry PR: the refresh itself was
already instrumented (auth_refresh_soft_failure / auth_logged_out fire
inside getValidClineCredentials), but the new retry transition was not.
The recovered flag counts runs that would previously have died with the
raw provider 401 — the direct production measure of this fix working.
2026-07-18 20:08:18 -07:00
Saoud Rizwan 0b7b9c1b3d fix(llms): add Kimi K3 to bundled ClinePass model fallback (#12392)
* fix(llms): add cline-pass/kimi-k3 to bundled model catalog fallback

* fix(llms): derive cline-pass default model from catalog authored order

Adding kimi-k3 (newest releaseDate) to the bundled cline-pass catalog
would have flipped firstGeneratedModelId — which sorts by release date —
to cline-pass/kimi-k3, silently changing the default model for new
ClinePass setups. Use the catalog's authored order instead, which mirrors
the recommended-models endpoint's curated order (intended default first,
subscription models before free ones).
2026-07-18 20:03:19 -07:00
Dominic Cooney 557d725690 fix(vscode): shell mismatch between prompt, execution, and user configuration on Windows (#12331)
* Rationalize shell identification and prompting, especially on Windows.

* Probe all pwsh install locations for the Windows default shell.

The default-shell fallback only checked the Program Files pwsh path,
so Microsoft Store installs of PowerShell 7 fell back to Windows
PowerShell while VS Code's own terminal launched pwsh. Share one
candidate list between the sync default-shell check and the async
PowerShell prober. Also drop an 'as string' cast that hid the
setting's type from the checker.

* Address shell resolution review feedback

* Resolve array-valued terminal profile paths on macOS and Linux too

VS Code permits terminal profile 'path' to be string | string[] on every
platform, not just Windows. The resolver (env expansion, first-existing
selection, PATH lookup) is now platform-generic: it uses the host path
module's separators and delimiter, probes PATHEXT only on Windows, and
treats env var names case-insensitively only on Windows. The macOS and
Linux getters route through it instead of returning the raw config value,
which crashed getShellKind() for array values.

* Apply terminal profile changes at the model-request boundary

A terminal profile change previously triggered a deferred session rebuild
to refresh the run_commands tool description. While a task was running the
rebuild waited, so the description could name one shell while commands
executed in another for the rest of the turn.

Instead of rebuilding, createShellTool now accepts a shell provider
function and re-derives the description each time the runtime reads it,
which happens exactly when a model request is built. The VS Code tool
snapshots {profileId, shell} in that provider; both execution paths (the
background spawn and the foreground terminal, via a new profile parameter
on getOrCreateTerminal) consume the snapshot. Commands produced by an
in-flight inference therefore run with the shell the model was told about,
and a mid-turn profile change takes effect when the tool results are sent
back: the next request names and uses the new shell.

The profile-change session rebuild path (handleTerminalProfileChanged) is
removed along with its deferred-rebuild window.

* Use the real createShellTool in the vitest @cline/core stub

The stub's hand-rolled createShellTool duplicated the 'shell must be a
string' invariant instead of exercising the code that enforces it
(getShellKind via description building), so the array-valued-profile
regression test proved only that the stub threw, not that the real tool
survives. Re-export the real implementation from SDK source — the same
pattern the stub already uses for the apply-patch and editor executors —
and assert on the actual generated descriptions, including that a profile
change is reflected at the next description read.

* Harden shell profile path resolution edge cases

- Warn and skip profile paths containing variable references beyond
  \ (e.g. \) instead of silently probing a
  literal path that can never exist; later candidates and the platform
  default still apply.
- Document that an overriding bash executor in createBuiltinTools bypasses
  the resolved canonical shell and must honor it to keep the run_commands
  description truthful.
2026-07-18 04:23:00 +02:00
Saoud Rizwan 7274d8badc feat(ui): add agent chat components, Storybook, and npm releases (#12374)
* feat(ui): add shared agent chat components and Storybook

* ci(ui): add standalone npm publishing

* docs(ui): keep release commands environment-neutral

* refactor(ui): simplify package validation

* refactor(ui): tighten package and release contracts

* docs(ui): remove duplicate install guidance

* ci(ui): make publishing workflow manual-only
2026-07-17 18:22:57 -07:00
Bee d1837366c0 chore(llms): update model catalog (#12366)
Update model catalog with bun run build:models
Version updated to 1784318695007
2026-07-17 13:28:59 -07:00
Saoud Rizwan c380daf4a3 docs(ui): add adoption primer (#12367) 2026-07-17 13:21:15 -07:00
Bee c564045d81 chore(cli): includes version numbers in hub status output (#12358)
Includes version numbers in hub status output and doctor command to make debugging with user easier.
2026-07-17 05:37:54 +02:00
71 changed files with 5284 additions and 977 deletions
+158
View File
@@ -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."
+145
View File
@@ -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}'"
+13
View File
@@ -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 -1
View File
@@ -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": {
+35
View File
@@ -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);
+7
View File
@@ -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}`,
+4
View File
@@ -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",
});
});
+3
View File
@@ -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,
}),
);
}),
+59
View File
@@ -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);
});
});
+9 -2
View File
@@ -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"))
);
}
+6
View File
@@ -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 } }),
+44 -12
View File
@@ -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>
+128 -5
View File
@@ -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
+2 -9
View File
@@ -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
View File
@@ -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
}
+217 -207
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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 | - |
+11
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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.
+14 -4
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+8
View File
@@ -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). */
+6 -1
View File
@@ -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,
+6 -1
View File
@@ -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,
+21 -1
View File
@@ -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",
);
});
});
+36 -9
View File
@@ -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];
}
}
+3 -2
View File
@@ -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));
}
+21
View File
@@ -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;
+22
View File
@@ -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);
}
+56
View File
@@ -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;
+497
View File
@@ -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
View File
@@ -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>
);
}
+52 -4
View File
@@ -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"
}
}
+88
View File
@@ -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>
);
+204
View File
@@ -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();
});
});
+24
View File
@@ -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");
});
});
+1
View File
@@ -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",
+15
View File
@@ -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"]
}
+14 -2
View File
@@ -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"
]
}
+1 -1
View File
@@ -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}"],
},
});