mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e31fb9e0d | ||
|
|
e0eb0167da | ||
|
|
d5748b2939 | ||
|
|
ffd6a6b1db | ||
|
|
51784a3bf1 | ||
|
|
149abb0ddb | ||
|
|
b3cee3f973 | ||
|
|
c68f553856 | ||
|
|
fd794ce3be | ||
|
|
f5f1071af2 | ||
|
|
4540390096 | ||
|
|
b590e14b91 | ||
|
|
54cc156089 | ||
|
|
6c599d18c3 | ||
|
|
513aacc0e6 | ||
|
|
ff2f860941 | ||
|
|
62a6b5a0b2 | ||
|
|
0caf617b50 | ||
|
|
930575991d | ||
|
|
e40d7d44ae | ||
|
|
e973ce4f33 | ||
|
|
4f25692d70 | ||
|
|
b0bba2e5d6 | ||
|
|
a6e2c0c431 | ||
|
|
d011d049a1 | ||
|
|
e09afced69 | ||
|
|
3f83be51a2 | ||
|
|
1efe5577e1 | ||
|
|
9cbc24d6e5 | ||
|
|
551286d33b | ||
|
|
361ac90978 | ||
|
|
4eb7402334 | ||
|
|
ffb61a865f | ||
|
|
d3616b96ae | ||
|
|
98e5458b09 | ||
|
|
031b8d94e1 | ||
|
|
e5bcba8ef9 | ||
|
|
83182c0d96 | ||
|
|
40ebd09dfa | ||
|
|
7bc18f7e15 | ||
|
|
fad8006730 | ||
|
|
28f35a2ec8 | ||
|
|
1e24807d1a | ||
|
|
a5f90e0d53 | ||
|
|
adabfc6bd5 | ||
|
|
397d6f344f | ||
|
|
8ca3068b73 | ||
|
|
6e6befdb65 | ||
|
|
71536e55aa | ||
|
|
7348ba1847 | ||
|
|
3e96fc6112 | ||
|
|
d84d09c543 | ||
|
|
6f7f817d63 | ||
|
|
c18d9478ba | ||
|
|
574b8eb45e | ||
|
|
81cce3d70e | ||
|
|
e1352fa709 | ||
|
|
394fb04518 | ||
|
|
f1aebbfd5a | ||
|
|
543dd0d818 | ||
|
|
1f2cbbeb9f | ||
|
|
b1a89156d6 | ||
|
|
1d7d9ce5e2 | ||
|
|
78b7c3d8ac | ||
|
|
d626cfb0b5 | ||
|
|
e14f354c59 | ||
|
|
41ba332f0a | ||
|
|
6997fae815 | ||
|
|
5594512eb0 | ||
|
|
6712d43c69 | ||
|
|
bd27d9c41b | ||
|
|
21edad82a6 | ||
|
|
2f58bbe4ed | ||
|
|
ddfb67515b | ||
|
|
472f9c88c5 | ||
|
|
f77d0930ba | ||
|
|
d1462cf919 | ||
|
|
49d33aa793 | ||
|
|
9b5dcc6405 | ||
|
|
f2ac1ef10a | ||
|
|
10da3bf5d6 | ||
|
|
0034efe48c | ||
|
|
64993e78d5 | ||
|
|
06f31f2821 | ||
|
|
71e6b44ef7 | ||
|
|
0bcd602150 | ||
|
|
d759f4c646 | ||
|
|
b035bf9255 | ||
|
|
ca9cb7f554 | ||
|
|
1ae2f71a0f | ||
|
|
62c57a0ccd | ||
|
|
f3c8b6748b | ||
|
|
e6104cfd2c | ||
|
|
12431cd97b | ||
|
|
2b34b48ec6 | ||
|
|
dc7eb755cf | ||
|
|
accd7e5809 | ||
|
|
400ba47387 | ||
|
|
3af23c1c4c | ||
|
|
ce04e80909 | ||
|
|
058c8c90a2 | ||
|
|
0f7e4b66ef | ||
|
|
f1c45b63c8 | ||
|
|
d6d1c789b3 | ||
|
|
3e81006863 | ||
|
|
8ea52a2eea | ||
|
|
5ec2d47b21 | ||
|
|
46fcde0a96 | ||
|
|
fbaa44be96 | ||
|
|
aec350bb9d | ||
|
|
16129cf90a | ||
|
|
3520786f4a | ||
|
|
8f0ff70215 | ||
|
|
0619e5a016 | ||
|
|
2a0dd197bf | ||
|
|
82e3596f96 | ||
|
|
895ab53b78 | ||
|
|
b8f51b9e55 | ||
|
|
25dc89eab4 | ||
|
|
5fdd840d5f | ||
|
|
cdcaa74422 | ||
|
|
5acc98474a | ||
|
|
53a5266239 | ||
|
|
1654517614 | ||
|
|
bbfcbcd31d | ||
|
|
102ef1ab84 | ||
|
|
55d1476169 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
|
||||
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish Cline Code
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
|
||||
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
|
||||
|
||||
## Release contract
|
||||
|
||||
@@ -17,7 +17,7 @@ Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon
|
||||
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (universal DMG + updater artifact + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
@@ -90,9 +90,22 @@ gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
**The run pauses for approval.** `validate` runs immediately, then the `build`
|
||||
job waits on the `PublishDesktop` environment until a required reviewer approves
|
||||
it — the run sits in `waiting`, which is expected, not a hang. Approve it in the
|
||||
run's web UI ("Review deployments"), or:
|
||||
|
||||
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
|
||||
```sh
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
--method POST -f state=approved -f comment="desktop vX.Y.Z" \
|
||||
-F 'environment_ids[]=19152605990' # PublishDesktop
|
||||
```
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`), verifies every Mach-O in the bundle carries both slices, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
@@ -100,17 +113,30 @@ If the workflow fails on missing credentials, see "Repo secrets (one-time setup)
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new `desktop-vX.Y.Z` universal `.app.tar.gz` asset (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
|
||||
## Repo secrets (one-time setup)
|
||||
## Publish secrets (one-time setup)
|
||||
|
||||
The workflow needs these repository secrets. The Apple ones come from the same
|
||||
Apple Developer account used for manual signing (see the app README's "macOS
|
||||
signing & notarization" section for how to obtain them):
|
||||
These live on the **`PublishDesktop` environment**, not at repository level, so
|
||||
only the `build` job can read them and only after an approval. Set them under
|
||||
Settings → Environments → PublishDesktop → Environment secrets. The environment
|
||||
also restricts deployments to `main` and requires a reviewer.
|
||||
|
||||
Adding one of these as a *repository* secret is the common mistake. The build
|
||||
would still succeed — an environment-gated job resolves repository secrets too,
|
||||
with environment values simply taking precedence — so the credential would sit
|
||||
repo-wide while everything looked fine. `validate` therefore fails the run if any
|
||||
of them resolves in a job with no environment. If you hit that, delete the
|
||||
repository-level copy rather than duplicating it.
|
||||
|
||||
If a secret is missing everywhere, the preflight in `build` fails the run naming
|
||||
the missing entries. The Apple values come from the same Apple Developer account
|
||||
used for manual signing (see the app README's "macOS signing & notarization"
|
||||
section for how to obtain them):
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
@@ -124,4 +150,8 @@ signing & notarization" section for how to obtain them):
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
|
||||
|
||||
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
|
||||
OTEL settings) are shared with the CLI publish workflow and already configured.
|
||||
`ERROR_SERVICE_API_KEY`, OTEL settings) are shared with the CLI, SDK, and extension
|
||||
publish workflows and already configured. **Do not move these into
|
||||
`PublishDesktop`** — scoping them to this environment empties them in every other
|
||||
publish workflow, silently, with no error beyond missing telemetry and a failed
|
||||
Slack post.
|
||||
|
||||
@@ -31,6 +31,45 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
# Companion to the presence check in `build`, and the half that actually
|
||||
# establishes scope. This job declares no environment, so a signing secret
|
||||
# that resolves here can only be a repository or organization secret —
|
||||
# meaning it is still readable by every workflow in the repo, which is the
|
||||
# thing the PublishDesktop environment exists to prevent. Neither check
|
||||
# proves provenance alone (an environment-gated job resolves repository
|
||||
# secrets too, with environment values merely taking precedence), but
|
||||
# together they do: empty here plus present in `build` means the value came
|
||||
# from the environment.
|
||||
- name: Verify signing secrets are not repository-scoped
|
||||
env:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
unscoped=()
|
||||
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
|
||||
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -z "${!name}" ] || unscoped+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#unscoped[@]} -gt 0 ]; then
|
||||
echo "These signing secrets resolve in a job with no environment:"
|
||||
printf ' - %s\n' "${unscoped[@]}"
|
||||
echo
|
||||
echo "That means they are still repository or organization secrets and"
|
||||
echo "are readable by any workflow in this repo. Delete them at that"
|
||||
echo "level and add them to the PublishDesktop environment instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "No signing secret resolves outside the PublishDesktop environment."
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -79,19 +118,59 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (${{ matrix.arch }})
|
||||
name: Build macOS (universal)
|
||||
needs: validate
|
||||
# The Apple signing/notarization and Tauri updater secrets live in the
|
||||
# PublishDesktop environment rather than at repository level, so they are
|
||||
# readable only by this job and only once a required reviewer approves the
|
||||
# run. Defense in depth: this `if` is advisory because a dispatched branch
|
||||
# runs its own copy of this file; the enforced gate is the PublishDesktop
|
||||
# environment's deployment-branch policy, which must also allow only main.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
arch: aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
arch: x86_64
|
||||
steps:
|
||||
# A secret missing here is dangerous rather than merely broken: Tauri skips
|
||||
# code signing when APPLE_CERTIFICATE is empty and skips notarization when
|
||||
# APPLE_API_KEY is empty, both silently, so the build would still succeed
|
||||
# and publish an unsigned, un-notarized bundle. Only the missing updater
|
||||
# key is caught later (by the .sig check in "Collect artifacts"). Fail up
|
||||
# front instead, before any build work, if the environment is misconfigured.
|
||||
- name: Verify PublishDesktop secrets are present
|
||||
env:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
|
||||
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing from the PublishDesktop environment:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "Check that every secret above is set on the PublishDesktop"
|
||||
echo "environment and that this job still declares"
|
||||
echo "'environment: PublishDesktop'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deliberately not phrased as "resolved from PublishDesktop": a
|
||||
# non-empty value here could also be a repository or organization
|
||||
# secret. The repository-scope check in `validate` is what rules that
|
||||
# out.
|
||||
echo "All 8 signing secrets are present."
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -102,16 +181,18 @@ jobs:
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# A universal (fat) macOS bundle needs both architecture slices, so
|
||||
# install both Rust targets; `tauri build --target universal-apple-darwin`
|
||||
# compiles each and lipos the results into one binary.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/examples/desktop-app/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
# No Rust build cache here, deliberately. This is the only job that can
|
||||
# read the Apple signing certificate and the Tauri updater key, and a
|
||||
# restored cache archive is attacker-controlled the moment the Actions
|
||||
# cache is poisoned.
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -142,8 +223,22 @@ jobs:
|
||||
|
||||
- name: Build, sign, and notarize desktop bundle
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
|
||||
run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
|
||||
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
|
||||
# this step and inlines these values into the binary via `--define`
|
||||
# (scripts/telemetry-define-args.ts); a packaged app launched from
|
||||
# Finder/the Dock has no runtime env, so build-time inlining is the
|
||||
# only way the shipped sidecar can ever report telemetry.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# Developer ID signing (Tauri imports the cert into a temp keychain)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -157,14 +252,64 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
# carries both slices before anything is published. A single-arch
|
||||
# sidecar would otherwise ship fine and only crash on the other arch.
|
||||
- name: Verify bundle is a universal binary
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Cline Code.app"
|
||||
if [ ! -d "$APP" ]; then
|
||||
echo "app bundle not found at $APP"
|
||||
exit 1
|
||||
fi
|
||||
for bin in "$APP/Contents/MacOS/"*; do
|
||||
archs=$(lipo -archs "$bin")
|
||||
echo "$bin: $archs"
|
||||
case "$archs" in
|
||||
*arm64*x86_64*|*x86_64*arm64*) ;;
|
||||
*)
|
||||
echo "$bin is not a universal binary (archs: $archs)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Guardrail: assert the telemetry config actually made it into the
|
||||
# compiled sidecar. Missing env on the build step (or a regression in
|
||||
# the --define inlining) would otherwise ship a release with telemetry
|
||||
# silently disabled — exactly what happened for every release before
|
||||
# this check existed. Being enabled is not enough on its own: an empty,
|
||||
# malformed, or non-http(s) OTLP endpoint would still drop every event
|
||||
# at runtime (the SDK exporters speak OTLP http/json only), so the
|
||||
# selfcheck must also report a usable endpoint host.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build, sign, and notarize desktop bundle' step and the"
|
||||
echo "--define inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
|
||||
echo "every event would be dropped at runtime. Check the"
|
||||
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect artifacts
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
|
||||
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
@@ -173,22 +318,22 @@ jobs:
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_universal.dmg"
|
||||
|
||||
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
|
||||
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.arch }}
|
||||
name: desktop-universal
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ jobs:
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
@@ -92,3 +92,4 @@ apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
apps/examples/desktop-app/.cursor/settings.json
|
||||
|
||||
+117
@@ -1,5 +1,122 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
|
||||
- Support Fable 5 on Vertex.
|
||||
|
||||
### Changed
|
||||
|
||||
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
|
||||
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
|
||||
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respect your configured max output tokens when the compaction summarizer requests a summary.
|
||||
- Remove the stale "Double-Check Completion" feature tip.
|
||||
|
||||
## [4.1.7]
|
||||
|
||||
### Added
|
||||
|
||||
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
|
||||
- Bring back a copy button on turn-final response rows.
|
||||
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
|
||||
|
||||
### Changed
|
||||
|
||||
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
|
||||
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
|
||||
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
|
||||
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
|
||||
- Settle the turn phase when a mode switch aborts a running turn.
|
||||
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
|
||||
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
|
||||
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
|
||||
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
|
||||
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
|
||||
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
|
||||
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
|
||||
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
|
||||
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
|
||||
|
||||
## [4.1.6]
|
||||
|
||||
### Added
|
||||
|
||||
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
|
||||
|
||||
## [4.1.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
|
||||
|
||||
### Changed
|
||||
|
||||
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
|
||||
|
||||
## [4.1.4]
|
||||
|
||||
### Added
|
||||
|
||||
- Recognize Chutes as a provider.
|
||||
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
|
||||
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
|
||||
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
|
||||
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
|
||||
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
|
||||
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
|
||||
- Show installed plugins under their real package names instead of all appearing as "index".
|
||||
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
|
||||
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
|
||||
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
|
||||
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
|
||||
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
|
||||
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
|
||||
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
|
||||
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
|
||||
|
||||
## [4.1.3]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop the two bundles of the combined rollout package from invalidating each other's Cline account session. A still-open legacy window that refreshed its token after the machine was promoted to the new extension would consume the shared refresh token, producing spurious "Unauthorized" / re-authenticate prompts and unexpected sign-outs. Promoted legacy windows now keep working on their current session and offer a one-time Reload Window prompt instead.
|
||||
- Fall back to the default Cline model when migrating a setup that references a model id the new extension doesn't recognize, instead of leaving the provider unconfigured.
|
||||
- Restore reliable checkpoints: checkpoints are created consistently, and restoring one now rewinds the whole workspace rather than a subset of files.
|
||||
- Keep settings edits that are made before the provider config finishes loading — base URLs, API keys, and the Qwen/Moonshot API line are no longer silently discarded.
|
||||
- Stop losing keystrokes in custom base URL fields, and keep the custom URL checkbox state after a failed clear.
|
||||
- Use the AskSage custom API URL at inference time instead of ignoring it.
|
||||
- Settle a pending tool approval when an edited message replaces the session, so the task no longer hangs waiting on a prompt that is gone.
|
||||
- Drop attachments from messages that have been edited.
|
||||
- Complete terminal commands when the shell execution ends, so tasks no longer stall on commands that already finished.
|
||||
- Include untracked files when generating commit messages.
|
||||
- Run Windows Store PowerShell profiles correctly.
|
||||
- Surface the upstream provider error when a gateway-forwarded stream fails, instead of a generic failure.
|
||||
- Retry empty Ollama responses at the model boundary, and raise the response-start timeout to 5 minutes so cold model loads no longer error out.
|
||||
- Show proper display names for Cline free models and recommended models in the model picker.
|
||||
- Preserve video input capability for models that support it.
|
||||
- Keep the plan/act input border in sync with the actual textarea focus.
|
||||
|
||||
## [4.1.2]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ We're thrilled you're interested in contributing to Cline. Whether you're fixing
|
||||
Bug reports help make Cline better for everyone! Before creating a new issue, please [search existing ones](https://github.com/cline/cline/issues) to avoid duplicates. When you're ready to report a bug, head over to our [issues page](https://github.com/cline/cline/issues/new/choose) where you'll find a template to help you with filling out the relevant information.
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
|
||||
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">GitHub security tool to report it privately</a>.
|
||||
</blockquote>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.52
|
||||
|
||||
- Added `cline mcp uninstall` for removing an installed MCP server
|
||||
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
|
||||
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
|
||||
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
|
||||
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
|
||||
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
|
||||
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
|
||||
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
|
||||
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
|
||||
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
|
||||
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
|
||||
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
|
||||
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
|
||||
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
|
||||
|
||||
## 3.0.51
|
||||
|
||||
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
|
||||
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
|
||||
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
|
||||
|
||||
## 3.0.50
|
||||
|
||||
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
|
||||
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
|
||||
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
|
||||
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
|
||||
- Auto-approval settings are now honored over ACP
|
||||
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
|
||||
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
|
||||
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
|
||||
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
|
||||
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
|
||||
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
|
||||
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
|
||||
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
|
||||
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
|
||||
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
|
||||
|
||||
## 3.0.49
|
||||
|
||||
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
|
||||
- Checkpoints are actually created again; a run-boundary regression meant none were ever recorded in the CLI (from SDK v0.0.69)
|
||||
- Checkpoint restore is now a full workspace rewind: files Cline created during the task come back at their checkpoint-time content and files created after the checkpoint are removed, while `.gitignore`d paths (build output, `node_modules`, `.env`) are left alone (from SDK v0.0.69)
|
||||
- After a restore, the rewound message is prefilled as plain text instead of the raw `<user_input mode="act">` envelope
|
||||
- Ollama's response-start timeout is now 5 minutes instead of 30 seconds, so cold-loading a large local model no longer errors out mid-load (from SDK v0.0.69)
|
||||
- Empty Ollama responses are now retried instead of failing the task with "Model returned empty response" (from SDK v0.0.69)
|
||||
- Migrated users whose stored Cline model id isn't in the catalog now fall back to the default model instead of sending an unknown model id on every request (from SDK v0.0.69)
|
||||
- The ClinePass promo dialog can be dismissed with any key (Enter still opens the subscription page), and it is marked as shown when it appears, so force-quitting no longer replays it on every launch
|
||||
- Opening a URL no longer crashes the CLI on hosts without an opener binary (headless Linux without `xdg-open`); WSL2 containers now use `xdg-open`, Windows tries the absolute PowerShell path first, and `cline doctor log` converts Linux paths to `\\wsl$` UNC paths
|
||||
- The hub now restarts through the installed wrapper after a Unix self-update, so npm cannot reuse a deleted cached executable
|
||||
- ACP: ClinePass is selectable as a provider, organizations can be selected, session resolution and text rendering on session restart are fixed, and agent errors now describe the actual failure
|
||||
- Provider errors forwarded through the Vercel AI Gateway now surface the real upstream message instead of a raw Zod dump or `[object Object]` (from SDK v0.0.68)
|
||||
- Cline free models and recommended models now show their real display names in the model picker (from SDK v0.0.68)
|
||||
- Sessions rooted at the filesystem root (`/`) no longer fail every command (from SDK v0.0.68)
|
||||
- On Windows, PowerShell commands now travel over UTF-8 stdin, so non-ASCII commands survive the active code page and long commands are not capped by the command-line limit (from SDK v0.0.68)
|
||||
- The live model catalog no longer drops the video input capability (from SDK v0.0.68)
|
||||
- Removed the CLI promo code flow
|
||||
|
||||
## 3.0.48
|
||||
|
||||
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
|
||||
|
||||
+4
-2
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
|
||||
|
||||
Schedule agents on cron-like intervals or external events.
|
||||
|
||||
If `--provider` and `--model` are omitted, schedules use the last configured
|
||||
provider and model. If only `--provider` is given, the schedule uses that
|
||||
provider's saved model.
|
||||
|
||||
```sh
|
||||
cline schedule create "Daily code review" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--prompt "Review PRs opened yesterday and summarize issues." \
|
||||
--workspace /path/to/repo \
|
||||
--provider cline \
|
||||
--model openai/gpt-5.3-codex \
|
||||
--timeout 3600 \
|
||||
--tags automation,review
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.48",
|
||||
"version": "3.0.52",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Launch the Cline CLI in ACP mode from source, for use as a Zed custom agent.
|
||||
#
|
||||
# Zed spawns agents without your interactive shell's PATH, so `bun` (installed
|
||||
# via mise/asdf/nvm/homebrew) is usually not resolvable. This wrapper finds bun
|
||||
# explicitly and execs it from the repo root.
|
||||
#
|
||||
# IMPORTANT: stdout is the JSON-RPC channel. Never echo to stdout here — any
|
||||
# stray byte corrupts the ACP stream. Diagnostics go to stderr.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
|
||||
# Prefer an explicit override, then PATH, then common version-manager locations.
|
||||
if [ -n "${BUN_BIN:-}" ]; then
|
||||
bun_bin="$BUN_BIN"
|
||||
elif command -v bun > /dev/null 2>&1; then
|
||||
bun_bin="$(command -v bun)"
|
||||
else
|
||||
bun_bin=""
|
||||
for candidate in \
|
||||
"$HOME"/.local/share/mise/installs/bun/*/bin/bun \
|
||||
"$HOME"/.bun/bin/bun \
|
||||
/opt/homebrew/bin/bun \
|
||||
/usr/local/bin/bun; do
|
||||
if [ -x "$candidate" ]; then
|
||||
bun_bin="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$bun_bin" ]; then
|
||||
echo "acp-dev.sh: could not find the 'bun' executable; set BUN_BIN to its path" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec "$bun_bin" --conditions=development --cwd apps/cli dev --acp "$@"
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
authenticateAcpProvider,
|
||||
isAcpAuthMethodId,
|
||||
} from "./auth";
|
||||
import {
|
||||
AUTO_APPROVE_CONFIG_ID,
|
||||
buildAutoApproveConfigOption,
|
||||
parseAutoApproveValue,
|
||||
} from "./auto-approve";
|
||||
import {
|
||||
buildOrganizationConfigOption,
|
||||
fetchClineOrganizations,
|
||||
@@ -75,6 +80,8 @@ interface SessionState {
|
||||
currentProviderId: string;
|
||||
/** Current model id for the session. */
|
||||
currentModelId: string;
|
||||
/** When true, all tool calls are approved without asking the client. */
|
||||
autoApproveTools: boolean;
|
||||
/** Active session manager for the running agent, if any. */
|
||||
sessionManager?: ClineCore;
|
||||
/** Internal session id within the session manager. */
|
||||
@@ -100,12 +107,17 @@ export class AcpAgent implements Agent {
|
||||
private sessions = new Map<string, SessionState>();
|
||||
private readonly conn: AgentSideConnection;
|
||||
private readonly providerSettingsManager = new ProviderSettingsManager();
|
||||
private readonly defaultAutoApproveTools: boolean;
|
||||
|
||||
/** Set after a successful `authenticate` call. */
|
||||
private authResult?: AcpAuthResult;
|
||||
|
||||
constructor(conn: AgentSideConnection) {
|
||||
constructor(
|
||||
conn: AgentSideConnection,
|
||||
options?: { autoApproveTools?: boolean },
|
||||
) {
|
||||
this.conn = conn;
|
||||
this.defaultAutoApproveTools = options?.autoApproveTools ?? false;
|
||||
}
|
||||
|
||||
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
@@ -190,6 +202,7 @@ export class AcpAgent implements Agent {
|
||||
currentMode: defaultMode,
|
||||
currentProviderId: providerId,
|
||||
currentModelId: defaultModelId,
|
||||
autoApproveTools: this.defaultAutoApproveTools,
|
||||
});
|
||||
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
@@ -217,6 +230,7 @@ export class AcpAgent implements Agent {
|
||||
await buildProviderConfigOption(providerId),
|
||||
buildModelConfigOption(defaultModelId, providerModels),
|
||||
buildModeConfigOption(defaultMode),
|
||||
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
|
||||
...(organizationOption ? [organizationOption] : []),
|
||||
],
|
||||
};
|
||||
@@ -254,6 +268,7 @@ export class AcpAgent implements Agent {
|
||||
process.env.CLINE_MODEL,
|
||||
providerModels,
|
||||
),
|
||||
autoApproveTools: this.defaultAutoApproveTools,
|
||||
};
|
||||
this.sessions.set(params.sessionId, session);
|
||||
}
|
||||
@@ -511,6 +526,18 @@ export class AcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
case AUTO_APPROVE_CONFIG_ID: {
|
||||
const autoApprove = parseAutoApproveValue(params.value);
|
||||
if (autoApprove === undefined) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid auto-approve value: ${String(params.value)} (must be a boolean)`,
|
||||
);
|
||||
}
|
||||
session.autoApproveTools = autoApprove;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
@@ -660,7 +687,9 @@ export class AcpAgent implements Agent {
|
||||
toolPolicies: config.toolPolicies,
|
||||
capabilities: {
|
||||
requestToolApproval: (request) =>
|
||||
requestAcpToolApproval(this.conn, acpSessionId, request),
|
||||
session.autoApproveTools
|
||||
? Promise.resolve({ approved: true })
|
||||
: requestAcpToolApproval(this.conn, acpSessionId, request),
|
||||
},
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
@@ -884,6 +913,7 @@ async function buildAllConfigOptions(
|
||||
providerOption,
|
||||
buildModelConfigOption(session.currentModelId, providerModels),
|
||||
buildModeConfigOption(session.currentMode),
|
||||
buildAutoApproveConfigOption(session.autoApproveTools),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTO_APPROVE_CONFIG_ID,
|
||||
buildAutoApproveConfigOption,
|
||||
parseAutoApproveValue,
|
||||
} from "./auto-approve";
|
||||
|
||||
describe("buildAutoApproveConfigOption", () => {
|
||||
it("builds a boolean config option reflecting the current value", () => {
|
||||
const option = buildAutoApproveConfigOption(true);
|
||||
|
||||
expect(option).toMatchObject({
|
||||
type: "boolean",
|
||||
id: AUTO_APPROVE_CONFIG_ID,
|
||||
currentValue: true,
|
||||
});
|
||||
expect(option.name).toBeTruthy();
|
||||
});
|
||||
|
||||
it("defaults to disabled when the session has it off", () => {
|
||||
const option = buildAutoApproveConfigOption(false);
|
||||
|
||||
expect(option).toMatchObject({ type: "boolean", currentValue: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAutoApproveValue", () => {
|
||||
it("accepts booleans", () => {
|
||||
expect(parseAutoApproveValue(true)).toBe(true);
|
||||
expect(parseAutoApproveValue(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the string forms sent by older clients", () => {
|
||||
expect(parseAutoApproveValue("true")).toBe(true);
|
||||
expect(parseAutoApproveValue("false")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed for unrecognized values", () => {
|
||||
expect(parseAutoApproveValue("yes")).toBeUndefined();
|
||||
expect(parseAutoApproveValue(1)).toBeUndefined();
|
||||
expect(parseAutoApproveValue(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no value was provided", () => {
|
||||
expect(parseAutoApproveValue(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
|
||||
export const AUTO_APPROVE_CONFIG_ID = "auto_approve";
|
||||
|
||||
export function buildAutoApproveConfigOption(
|
||||
currentValue: boolean,
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "boolean",
|
||||
id: AUTO_APPROVE_CONFIG_ID,
|
||||
name: "Auto-approve tools",
|
||||
description:
|
||||
"Automatically approve all tool calls without asking for permission",
|
||||
currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret the value of a `session/set_config_option` request for the
|
||||
* auto-approve option.
|
||||
*
|
||||
* The ACP schema sends booleans for boolean options, but clients that predate
|
||||
* boolean options may send the string form, so both are accepted. Returns
|
||||
* `undefined` for anything else so the caller can reject the request.
|
||||
*/
|
||||
export function parseAutoApproveValue(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean" || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value === "true" ? true : value === "false" ? false : undefined;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
export async function runAcpMode(): Promise<void> {
|
||||
export interface AcpModeOptions {
|
||||
autoApproveTools?: boolean;
|
||||
}
|
||||
|
||||
export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
|
||||
const { AgentSideConnection, ndJsonStream } = await import(
|
||||
"@agentclientprotocol/sdk"
|
||||
);
|
||||
@@ -15,7 +19,9 @@ export async function runAcpMode(): Promise<void> {
|
||||
);
|
||||
|
||||
const connection = new AgentSideConnection((conn) => {
|
||||
return new AcpAgent(conn);
|
||||
return new AcpAgent(conn, {
|
||||
autoApproveTools: options?.autoApproveTools,
|
||||
});
|
||||
}, stream);
|
||||
|
||||
// Keep the process alive until the connection closes
|
||||
|
||||
@@ -767,6 +767,71 @@ Break work into clear steps.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes mcp uninstall and its rm alias", () => {
|
||||
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
|
||||
tempDirs.push(tempRoot);
|
||||
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
|
||||
const writeSettings = () => {
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: { transport: { type: "stdio", command: "node" } },
|
||||
remote: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
customTopLevelKey: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
};
|
||||
const readServers = () =>
|
||||
(
|
||||
JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
}
|
||||
).mcpServers ?? {};
|
||||
|
||||
writeSettings();
|
||||
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(uninstallResult.status).toBe(0);
|
||||
expect(asText(uninstallResult.stdout)).toContain(
|
||||
"Uninstalled MCP server docs.",
|
||||
);
|
||||
expect(Object.keys(readServers())).toEqual(["remote"]);
|
||||
|
||||
writeSettings();
|
||||
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(aliasResult.status).toBe(0);
|
||||
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
|
||||
name: "remote",
|
||||
status: "uninstalled",
|
||||
});
|
||||
expect(Object.keys(readServers())).toEqual(["docs"]);
|
||||
|
||||
writeSettings();
|
||||
const missingResult = runCli(["mcp", "remove", "missing"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(missingResult.status).toBe(1);
|
||||
expect(asText(missingResult.stderr)).toContain(
|
||||
'MCP server "missing" is not installed.',
|
||||
);
|
||||
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
@@ -270,7 +271,7 @@ async function runPluginsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
pluginsByPath.set(filePath, {
|
||||
name: basename(filePath, extname(filePath)),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectIo } from "../connectors/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureDetachedHubServer: vi.fn(),
|
||||
readHubDiscovery: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
command: vi.fn(),
|
||||
close: vi.fn(),
|
||||
clientOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mocks.ensureDetachedHubServer,
|
||||
readHubDiscovery: mocks.readHubDiscovery,
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/production.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/owner.json",
|
||||
}),
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
mocks.clientOptions(options);
|
||||
}
|
||||
connect = mocks.connect;
|
||||
command = mocks.command;
|
||||
close = mocks.close;
|
||||
},
|
||||
}));
|
||||
|
||||
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
|
||||
|
||||
describe("startConnectorViaHub", () => {
|
||||
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ensureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: ["session.create", "connector.start"],
|
||||
});
|
||||
mocks.connect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function startRequest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
args: ["--bot-token", "xoxb"],
|
||||
io,
|
||||
cwd: "/workspace",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("hands the start to the hub and reports supervision", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: {
|
||||
started: true,
|
||||
record: { pid: 4242, state: "running" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.start", {
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
args: ["--bot-token", "xoxb"],
|
||||
restart: false,
|
||||
});
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("started under hub supervision pid=4242"),
|
||||
);
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a restart through", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: { started: true, record: { state: "running" } },
|
||||
});
|
||||
|
||||
await startConnectorViaHub(startRequest({ restart: true }));
|
||||
|
||||
expect(mocks.command).toHaveBeenCalledWith(
|
||||
"connector.start",
|
||||
expect.objectContaining({ restart: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an already-running instance as success", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: {
|
||||
started: false,
|
||||
reason: "already_running",
|
||||
record: { pid: 99, state: "running" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("already running under the hub"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when the hub cannot be reached", async () => {
|
||||
mocks.ensureDetachedHubServer.mockRejectedValue(new Error("EADDRINUSE"));
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome.delegated).toBe(false);
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when a running hub predates connector supervision", async () => {
|
||||
// The normal state of a long-lived host mid-upgrade: a new CLI, an old hub.
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: ["session.create"],
|
||||
});
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome).toEqual({
|
||||
delegated: false,
|
||||
reason: "hub does not support connector supervision",
|
||||
});
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when the hub reports supervision unavailable", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "connector_command_failed",
|
||||
message: "connector supervision is unavailable in this hub",
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome.delegated).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back when the hub command throws", async () => {
|
||||
mocks.command.mockRejectedValue(new Error("socket closed"));
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome.delegated).toBe(false);
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a genuine start refusal instead of starting locally", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "connector_command_failed",
|
||||
message: "instanceId is required",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 1,
|
||||
});
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hub refused to start slack"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a hub that accepted the command but did not start anything", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: {
|
||||
started: false,
|
||||
record: { state: "failed", lastError: "bad token" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 1,
|
||||
});
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bad token"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopConnectorsViaHub", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: [
|
||||
"connector.start",
|
||||
"connector.stop",
|
||||
"connector.supervised",
|
||||
],
|
||||
});
|
||||
mocks.connect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("retires every supervised instance of a channel", async () => {
|
||||
mocks.command.mockImplementation(async (command: string) => {
|
||||
if (command === "connector.supervised") {
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
supervised: [
|
||||
{ channel: "slack", instanceId: "a" },
|
||||
{ channel: "slack", instanceId: "b" },
|
||||
{ channel: "telegram", instanceId: "c" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, payload: { stopped: true } };
|
||||
});
|
||||
|
||||
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(2);
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "slack",
|
||||
instanceId: "a",
|
||||
});
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "slack",
|
||||
instanceId: "b",
|
||||
});
|
||||
// A different channel is left alone.
|
||||
expect(mocks.command).not.toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "telegram",
|
||||
instanceId: "c",
|
||||
});
|
||||
});
|
||||
|
||||
it("retires only the requested instance", async () => {
|
||||
mocks.command.mockImplementation(async (command: string) => {
|
||||
if (command === "connector.supervised") {
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
supervised: [
|
||||
{ channel: "slack", instanceId: "a" },
|
||||
{ channel: "slack", instanceId: "b" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, payload: { stopped: true } };
|
||||
});
|
||||
|
||||
await expect(
|
||||
stopConnectorsViaHub({ channel: "slack", instanceId: "b" }),
|
||||
).resolves.toBe(1);
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "slack",
|
||||
instanceId: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nothing to stop when the hub supervises none of them", async () => {
|
||||
mocks.command.mockResolvedValue({ ok: true, payload: { supervised: [] } });
|
||||
|
||||
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("returns undefined when the hub cannot supervise", async () => {
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: ["session.create"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
stopConnectorsViaHub({ channel: "slack" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
ensureDetachedHubServer,
|
||||
NodeHubClient,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type ConnectorStartResult,
|
||||
resolveClineBuildEnv,
|
||||
type SupervisedConnectorRecord,
|
||||
} from "@cline/shared";
|
||||
import type { ConnectIo } from "../connectors/types";
|
||||
|
||||
/**
|
||||
* Error codes that mean "this hub cannot supervise connectors", as opposed to
|
||||
* "the start failed". Both are answers from the hub, but only the former should
|
||||
* send the caller back to starting the connector itself.
|
||||
*/
|
||||
const UNSUPPORTED_ERROR_CODES = new Set([
|
||||
"unsupported_command",
|
||||
"unsupported_connector_command",
|
||||
]);
|
||||
const UNSUPPORTED_MESSAGE_FRAGMENT = "connector supervision is unavailable";
|
||||
|
||||
export type HubDelegationOutcome =
|
||||
/** The hub owns the connector now; `exitCode` is the command's result. */
|
||||
| { delegated: true; exitCode: number }
|
||||
/** Nothing was started; the caller should start the connector locally. */
|
||||
| { delegated: false; reason: string };
|
||||
|
||||
function resolveHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the hub at `url` advertises connector supervision.
|
||||
*
|
||||
* A newer CLI regularly talks to an older running hub — that is the normal state
|
||||
* of a long-lived host mid-upgrade — and such a hub would reject
|
||||
* `connector.start` outright. Checking the advertised capability first keeps that
|
||||
* case on the local path instead of turning it into a failed start.
|
||||
*/
|
||||
async function hubSupportsSupervision(): Promise<boolean> {
|
||||
try {
|
||||
const owner = resolveHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
return record?.capabilities?.includes("connector.start") === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeRecord(record: SupervisedConnectorRecord | undefined): string {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
const details = [
|
||||
record.pid === undefined ? undefined : `pid=${record.pid}`,
|
||||
`state=${record.state}`,
|
||||
].filter(Boolean);
|
||||
return details.length > 0 ? ` ${details.join(" ")}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running hub is supervising, or undefined when it cannot say.
|
||||
*
|
||||
* Deliberately does not start a hub: this exists for diagnostics, and `cline
|
||||
* doctor` reporting on the system must never change it.
|
||||
*/
|
||||
export async function listSupervisedConnectorsViaHub(): Promise<
|
||||
SupervisedConnectorRecord[] | undefined
|
||||
> {
|
||||
let url: string;
|
||||
let authToken: string | undefined;
|
||||
try {
|
||||
const owner = resolveHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (
|
||||
!record?.url ||
|
||||
!record.capabilities?.includes("connector.supervised")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
url = record.url;
|
||||
authToken = record.authToken;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
...(authToken ? { authToken } : {}),
|
||||
clientType: "cli-doctor",
|
||||
displayName: "doctor",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const reply = await client.command("connector.supervised");
|
||||
if (!reply.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const supervised = (reply.payload as { supervised?: unknown })?.supervised;
|
||||
return Array.isArray(supervised)
|
||||
? (supervised as SupervisedConnectorRecord[])
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// One-shot connection; a failed close changes nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the hub to stop supervising a channel's connectors, or one instance of it.
|
||||
*
|
||||
* Returns how many the hub stopped, or undefined when it cannot supervise. The
|
||||
* local stop path alone is not enough: it finds processes through their state
|
||||
* files, so a connector that has not written one yet — still starting, or failing
|
||||
* to start — would keep running under the hub and be restarted.
|
||||
*/
|
||||
export async function stopConnectorsViaHub(input: {
|
||||
channel: string;
|
||||
instanceId?: string;
|
||||
}): Promise<number | undefined> {
|
||||
const supervised = await listSupervisedConnectorsViaHub();
|
||||
if (!supervised) {
|
||||
return undefined;
|
||||
}
|
||||
const targets = supervised.filter(
|
||||
(record) =>
|
||||
record.channel === input.channel &&
|
||||
(input.instanceId === undefined ||
|
||||
record.instanceId === input.instanceId),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let url: string;
|
||||
let authToken: string | undefined;
|
||||
try {
|
||||
const owner = resolveHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!record?.url) {
|
||||
return undefined;
|
||||
}
|
||||
url = record.url;
|
||||
authToken = record.authToken;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
...(authToken ? { authToken } : {}),
|
||||
clientType: "cli-connect",
|
||||
displayName: `stop ${input.channel}`,
|
||||
});
|
||||
let stopped = 0;
|
||||
try {
|
||||
await client.connect();
|
||||
for (const target of targets) {
|
||||
const reply = await client.command("connector.stop", {
|
||||
channel: target.channel,
|
||||
instanceId: target.instanceId,
|
||||
});
|
||||
if (reply.ok) {
|
||||
stopped += 1;
|
||||
}
|
||||
}
|
||||
return stopped;
|
||||
} catch {
|
||||
return stopped > 0 ? stopped : undefined;
|
||||
} finally {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// One-shot connection; a failed close changes nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the hub to start and own a connector.
|
||||
*
|
||||
* The hub spawning the connector — rather than the connector spawning itself and
|
||||
* then bringing up a hub — is what makes the hub the single authority on how many
|
||||
* processes hold one connector's credentials, and what lets it reap and restart
|
||||
* them when they die. Every failure mode here falls back to the local path so a
|
||||
* missing or older hub cannot stop a connector from starting.
|
||||
*/
|
||||
export async function startConnectorViaHub(input: {
|
||||
channel: string;
|
||||
instanceId: string;
|
||||
args: string[];
|
||||
restart?: boolean;
|
||||
io: ConnectIo;
|
||||
cwd?: string;
|
||||
}): Promise<HubDelegationOutcome> {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
let hub: { url: string; authToken: string };
|
||||
try {
|
||||
hub = await ensureDetachedHubServer(cwd);
|
||||
} catch (error) {
|
||||
return {
|
||||
delegated: false,
|
||||
reason: `hub unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
if (!(await hubSupportsSupervision())) {
|
||||
return {
|
||||
delegated: false,
|
||||
reason: "hub does not support connector supervision",
|
||||
};
|
||||
}
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url: hub.url,
|
||||
authToken: hub.authToken,
|
||||
clientType: "cli-connect",
|
||||
displayName: `connect ${input.channel}`,
|
||||
cwd,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const reply = await client.command("connector.start", {
|
||||
channel: input.channel,
|
||||
instanceId: input.instanceId,
|
||||
args: input.args,
|
||||
restart: input.restart === true,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
const code = reply.error?.code ?? "";
|
||||
const message = reply.error?.message ?? "connector start failed";
|
||||
if (
|
||||
UNSUPPORTED_ERROR_CODES.has(code) ||
|
||||
message.includes(UNSUPPORTED_MESSAGE_FRAGMENT)
|
||||
) {
|
||||
return { delegated: false, reason: message };
|
||||
}
|
||||
input.io.writeErr(
|
||||
`[connect] hub refused to start ${input.channel}: ${message}`,
|
||||
);
|
||||
return { delegated: true, exitCode: 1 };
|
||||
}
|
||||
const payload = reply.payload as ConnectorStartResult | undefined;
|
||||
const record = payload?.record;
|
||||
if (payload?.started === false && payload.reason === "already_running") {
|
||||
input.io.writeln(
|
||||
`[connect] ${input.channel} connector ${input.instanceId} is already running under the hub${describeRecord(record)}`,
|
||||
);
|
||||
return { delegated: true, exitCode: 0 };
|
||||
}
|
||||
if (payload?.started !== true) {
|
||||
input.io.writeErr(
|
||||
`[connect] hub could not start ${input.channel} connector ${input.instanceId}${
|
||||
record?.lastError ? `: ${record.lastError}` : ""
|
||||
}`,
|
||||
);
|
||||
return { delegated: true, exitCode: 1 };
|
||||
}
|
||||
input.io.writeln(
|
||||
`[connect] ${input.channel} connector ${input.instanceId} started under hub supervision${describeRecord(record)}`,
|
||||
);
|
||||
input.io.writeln(
|
||||
"[connect] the hub will restart it if it exits; use `cline connect --stop` to retire it",
|
||||
);
|
||||
return { delegated: true, exitCode: 0 };
|
||||
} catch (error) {
|
||||
return {
|
||||
delegated: false,
|
||||
reason: `hub command failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// The connection is one-shot; a failed close changes nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "../connectors/common";
|
||||
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
|
||||
import {
|
||||
runCleanupConnectorInstance,
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
} from "./connect";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
startConnectorViaHub: vi.fn(),
|
||||
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
|
||||
disableConnectorAutostart: vi.fn(),
|
||||
getPersistedConnectorConnection: vi.fn(),
|
||||
getConnector: vi.fn(),
|
||||
@@ -36,6 +39,11 @@ vi.mock("../connectors/registry", () => ({
|
||||
listConnectors: mocks.listConnectors,
|
||||
}));
|
||||
|
||||
vi.mock("./connect-via-hub", () => ({
|
||||
startConnectorViaHub: mocks.startConnectorViaHub,
|
||||
stopConnectorsViaHub: mocks.stopConnectorsViaHub,
|
||||
}));
|
||||
|
||||
describe("runConnectAdapter", () => {
|
||||
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
|
||||
const io: ConnectIo = {
|
||||
@@ -492,3 +500,195 @@ describe("runConnectAdapter", () => {
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCleanupConnectorInstance", () => {
|
||||
const io: ConnectIo = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reaps one instance without disabling its autostart", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
});
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("slack", "cline-slack", io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline-slack", io);
|
||||
// The instance crashed; it was not retired. Disabling autostart here would
|
||||
// make every crash silently opt the connector out of supervision.
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed reap", async () => {
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance: vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("slack", "cline-slack", io),
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an adapter without per-instance stop", async () => {
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("slack", "cline-slack", io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
'connect adapter "slack" does not support per-instance stop',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown adapter", async () => {
|
||||
mocks.getConnector.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("nope", "instance", io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith('unknown connect adapter "nope"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub-delegated connector starts", () => {
|
||||
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listConnectors.mockReturnValue([]);
|
||||
mocks.listActiveConnectors.mockReturnValue([]);
|
||||
mocks.validate.mockResolvedValue(0);
|
||||
mocks.run.mockResolvedValue(0);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
resolveInstanceId: () => "cline-slack",
|
||||
});
|
||||
mocks.startConnectorViaHub.mockResolvedValue({
|
||||
delegated: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_CONNECTOR_SUPERVISED;
|
||||
});
|
||||
|
||||
it("asks the hub to own a background connector and records the intent", async () => {
|
||||
await expect(
|
||||
runConnectAdapter("slack", ["--bot-token", "xoxb"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.startConnectorViaHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
args: ["--bot-token", "xoxb"],
|
||||
}),
|
||||
);
|
||||
// The adapter must not also run here: the hub owns the process now.
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"slack",
|
||||
"cline-slack",
|
||||
["--bot-token", "xoxb"],
|
||||
);
|
||||
});
|
||||
|
||||
it("runs locally for a foreground connector", async () => {
|
||||
await runConnectAdapter("slack", ["--bot-token", "xoxb", "-i"], io);
|
||||
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs locally inside a supervised process instead of asking the hub again", async () => {
|
||||
process.env.CLINE_CONNECTOR_SUPERVISED = "1";
|
||||
|
||||
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
|
||||
|
||||
// Delegating here would send the hub straight back to spawning this same
|
||||
// process.
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs locally when the instance id cannot be known up front", async () => {
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
resolveInstanceId: () => undefined,
|
||||
});
|
||||
|
||||
await runConnectAdapter("telegram", ["-k", "token"], io);
|
||||
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a local start when the hub declines", async () => {
|
||||
mocks.startConnectorViaHub.mockResolvedValue({
|
||||
delegated: false,
|
||||
reason: "hub does not support connector supervision",
|
||||
});
|
||||
|
||||
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
|
||||
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not validate or delegate a help invocation", async () => {
|
||||
await runConnectAdapter("slack", ["--help"], io);
|
||||
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a validation failure without contacting the hub", async () => {
|
||||
mocks.validate.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("slack", ["--bot-token", "bad"], io),
|
||||
).resolves.toBe(2);
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
persistConnectorConnection,
|
||||
removePersistedConnectorConnection,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isSupervisedConnectorProcess,
|
||||
setStartingConnectorInstance,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
@@ -15,6 +19,7 @@ import type {
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../connectors/types";
|
||||
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
|
||||
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
|
||||
@@ -83,6 +88,20 @@ export async function runStopConnector(
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
return 1;
|
||||
}
|
||||
// Retire it with the hub first. The local stop below finds processes through
|
||||
// their state files, so a supervised connector that has not written one yet
|
||||
// would survive and be restarted.
|
||||
const stoppedByHub = await stopConnectorsViaHub({
|
||||
channel: connector.name,
|
||||
...(options.instanceId === undefined
|
||||
? {}
|
||||
: { instanceId: options.instanceId }),
|
||||
});
|
||||
if (stoppedByHub) {
|
||||
io.writeln(
|
||||
`[connect] hub stopped supervising ${stoppedByHub} ${connector.name} connector${stoppedByHub === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
const result = await stop();
|
||||
if (!result) {
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
@@ -97,6 +116,39 @@ export async function runStopConnector(
|
||||
return result.failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap one connector instance that is no longer running.
|
||||
*
|
||||
* Invoked by the hub supervisor when it observes a connector die. It clears the
|
||||
* same things a normal stop does — process state file, thread→session bindings,
|
||||
* the instance's hub sessions — but deliberately leaves the autostart record
|
||||
* intact: the instance crashed, it was not retired, so the supervisor still
|
||||
* intends to restart it. `runStopConnector` with `autostart: "disable"` would
|
||||
* make every crash silently opt the connector out of recovery.
|
||||
*/
|
||||
export async function runCleanupConnectorInstance(
|
||||
adapterName: string,
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
if (!connector.stopInstance) {
|
||||
io.writeErr(
|
||||
`connect adapter "${adapterName}" does not support per-instance stop`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const result = await connector.stopInstance(instanceId, io);
|
||||
io.writeln(
|
||||
`[connect] ${connector.name} instance=${instanceId} cleaned processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
|
||||
);
|
||||
return result.failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runRestartConnector(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
@@ -132,6 +184,21 @@ export async function runRestartConnector(
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
// The supervisor replaces an instance in one step, so let it do the whole
|
||||
// restart rather than stopping here and racing it to start the replacement.
|
||||
// Only when the target is the instance these arguments describe: a
|
||||
// `--restart-instance` pointing elsewhere is not ours to reinterpret.
|
||||
if (
|
||||
requestedInstanceId === undefined ||
|
||||
connector.resolveInstanceId?.(passthroughArgs) === requestedInstanceId
|
||||
) {
|
||||
const delegated = await tryDelegateToHub(connector, passthroughArgs, io, {
|
||||
restart: true,
|
||||
});
|
||||
if (delegated !== undefined) {
|
||||
return delegated;
|
||||
}
|
||||
}
|
||||
const previousConnection = getPersistedConnectorConnection(
|
||||
adapterName,
|
||||
instanceId,
|
||||
@@ -208,6 +275,14 @@ async function runConnectAdapterWithResult(
|
||||
},
|
||||
setPersistenceInstanceId: (instanceId) => {
|
||||
persistenceInstanceId = instanceId;
|
||||
// Adapters report their instance id before they build a Cline core, so
|
||||
// this lands in the environment before the hub daemon is spawned and
|
||||
// inherited by it. Without it the daemon's autostart pass cannot tell
|
||||
// that this instance is mid-startup and launches a second copy of it.
|
||||
setStartingConnectorInstance({
|
||||
channel: connector.name,
|
||||
instanceId,
|
||||
});
|
||||
},
|
||||
};
|
||||
const exitCode = await connector.run(passthroughArgs, io, context);
|
||||
@@ -218,8 +293,12 @@ async function runConnectAdapterWithResult(
|
||||
const isInteractiveInvocation = passthroughArgs.some((arg) =>
|
||||
INTERACTIVE_FLAGS.has(arg),
|
||||
);
|
||||
// A supervised process is the hub's own connector, not a user invocation, so
|
||||
// it makes the same autostart bookkeeping choices as a detached child: the
|
||||
// process that asked for the start already recorded the intent.
|
||||
const isDetachedChild =
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
|
||||
isSupervisedConnectorProcess();
|
||||
if (
|
||||
exitCode === 0 &&
|
||||
!isHelpInvocation &&
|
||||
@@ -242,11 +321,74 @@ async function runConnectAdapterWithResult(
|
||||
return { exitCode, instanceId: persistenceInstanceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a background connector start to the hub, when that is possible.
|
||||
*
|
||||
* Returns the exit code once the hub owns the connector, or undefined to mean
|
||||
* "start it locally instead". Delegation is skipped for foreground (`-i`) runs,
|
||||
* which are attached to the user's terminal, and for connectors the hub itself
|
||||
* launched, which would otherwise ask the hub to start them again.
|
||||
*/
|
||||
async function tryDelegateToHub(
|
||||
connector: {
|
||||
name: string;
|
||||
validate: (args: string[], io: ConnectIo) => Promise<number>;
|
||||
resolveInstanceId?: (args: string[]) => string | undefined;
|
||||
},
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
options: { restart?: boolean } = {},
|
||||
): Promise<number | undefined> {
|
||||
if (
|
||||
passthroughArgs.some((arg) => HELP_FLAGS.has(arg)) ||
|
||||
passthroughArgs.some((arg) => INTERACTIVE_FLAGS.has(arg)) ||
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
|
||||
isSupervisedConnectorProcess()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// Without an instance id the hub cannot enforce one process per connector,
|
||||
// which is the entire point of routing through it.
|
||||
const instanceId = connector.resolveInstanceId?.(passthroughArgs);
|
||||
if (!instanceId) {
|
||||
return undefined;
|
||||
}
|
||||
// Check the arguments here rather than after handing off: a bad token should
|
||||
// fail in front of the user instead of becoming a supervised crash loop.
|
||||
const validationExitCode = await connector.validate(passthroughArgs, io);
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
const outcome = await startConnectorViaHub({
|
||||
channel: connector.name,
|
||||
instanceId,
|
||||
args: passthroughArgs,
|
||||
...(options.restart === undefined ? {} : { restart: options.restart }),
|
||||
io,
|
||||
});
|
||||
if (!outcome.delegated) {
|
||||
return undefined;
|
||||
}
|
||||
if (outcome.exitCode === 0) {
|
||||
// Recorded here rather than in the hub-spawned process: this is the
|
||||
// invocation that expressed the intent to keep the connector running.
|
||||
persistConnectorConnection(connector.name, instanceId, passthroughArgs);
|
||||
}
|
||||
return outcome.exitCode;
|
||||
}
|
||||
|
||||
export async function runConnectAdapter(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (connector) {
|
||||
const delegated = await tryDelegateToHub(connector, passthroughArgs, io);
|
||||
if (delegated !== undefined) {
|
||||
return delegated;
|
||||
}
|
||||
}
|
||||
const result = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
passthroughArgs,
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
mockEnsureFileExists,
|
||||
mockListActiveConnectors,
|
||||
mockStopAllConnectors,
|
||||
mockListSupervisedConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
@@ -58,6 +59,7 @@ const {
|
||||
stoppedSessions: 0,
|
||||
executed: 0,
|
||||
})),
|
||||
mockListSupervisedConnectors: vi.fn(async () => undefined as unknown),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
@@ -84,7 +86,11 @@ vi.mock("./connect", () => ({
|
||||
stopAllConnectors: mockStopAllConnectors,
|
||||
}));
|
||||
|
||||
import { createDoctorCommand, runDoctorCommand } from "./doctor";
|
||||
vi.mock("./connect-via-hub", () => ({
|
||||
listSupervisedConnectorsViaHub: mockListSupervisedConnectors,
|
||||
}));
|
||||
|
||||
import { __test__, createDoctorCommand, runDoctorCommand } from "./doctor";
|
||||
|
||||
describe("runDoctorCommand", () => {
|
||||
const tempDirs: string[] = [];
|
||||
@@ -450,3 +456,171 @@ describe("createDoctorCommand log subcommand", () => {
|
||||
expect(errors[0]).toContain("open failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("container-aware process filtering", () => {
|
||||
const { decideForeignContainer, CONTAINER_CGROUP_PATTERN } = __test__;
|
||||
|
||||
it("treats a process in a different pid namespace as foreign", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [
|
||||
["pid:[4026531836]", "pid:[4026532500]"],
|
||||
[undefined, undefined],
|
||||
],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a sibling process in our own namespaces", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [
|
||||
["pid:[4026531836]", "pid:[4026531836]"],
|
||||
["mnt:[4026531840]", "mnt:[4026531840]"],
|
||||
],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to cgroup container ids when namespaces are unreadable", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [[undefined, undefined]],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
|
||||
}),
|
||||
).toBe(true);
|
||||
// Same container: our own sibling process, not something to retire.
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [[undefined, undefined]],
|
||||
ownContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
|
||||
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never filters off Linux, where containers cannot share our pid space", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "darwin",
|
||||
namespacePairs: [["pid:[1]", "pid:[2]"]],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: "abcdef123456",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts container ids from real cgroup paths", () => {
|
||||
const docker =
|
||||
"0::/system.slice/docker-7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad.scope";
|
||||
expect(docker.match(CONTAINER_CGROUP_PATTERN)?.[1]).toBe(
|
||||
"7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad",
|
||||
);
|
||||
// A plain host session must not look like a container.
|
||||
expect(
|
||||
"0::/user.slice/user-1001.slice/session-121.scope".match(
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor supervision reporting", () => {
|
||||
const { formatSupervisedConnector } = __test__;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListSupervisedConnectors.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
async function runDoctorJson(): Promise<Record<string, unknown>> {
|
||||
const output: string[] = [];
|
||||
await runDoctorCommand(
|
||||
{ cwd: "/workspace", json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
return JSON.parse(output[0] || "{}") as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("reports what the hub is supervising", async () => {
|
||||
mockListSupervisedConnectors.mockResolvedValue([
|
||||
{
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
state: "backoff",
|
||||
origin: "spawned",
|
||||
restarts: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(runDoctorJson()).resolves.toMatchObject({
|
||||
supervisedConnectors: [
|
||||
{ channel: "slack", instanceId: "cline-slack", state: "backoff" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits supervision when the hub cannot report it", async () => {
|
||||
mockListSupervisedConnectors.mockResolvedValue(undefined);
|
||||
|
||||
const status = await runDoctorJson();
|
||||
|
||||
expect(status.supervisedConnectors).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays usable when the supervision query fails", async () => {
|
||||
mockListSupervisedConnectors.mockRejectedValue(new Error("hub gone"));
|
||||
|
||||
// Diagnostics must degrade quietly rather than fail.
|
||||
const status = await runDoctorJson();
|
||||
|
||||
expect(status.supervisedConnectors).toBeUndefined();
|
||||
expect(status).toHaveProperty("hubHealthy");
|
||||
});
|
||||
|
||||
it("formats restart and failure state so a crash loop is visible", () => {
|
||||
expect(
|
||||
formatSupervisedConnector({
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
state: "failed",
|
||||
origin: "adopted",
|
||||
pid: 42,
|
||||
restarts: 5,
|
||||
lastExitCode: 1,
|
||||
lastError: "invalid token",
|
||||
}),
|
||||
).toBe(
|
||||
"slack | instance=cline-slack | state=failed | origin=adopted | pid=42 | restarts=5 | lastExit=1 | error=invalid token",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out fields that do not apply to a healthy connector", () => {
|
||||
expect(
|
||||
formatSupervisedConnector({
|
||||
channel: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
state: "running",
|
||||
origin: "spawned",
|
||||
pid: 7,
|
||||
restarts: 0,
|
||||
}),
|
||||
).toBe(
|
||||
"telegram | instance=cline_bot | state=running | origin=spawned | pid=7",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ActiveConnectorRecord,
|
||||
formatUptime,
|
||||
resolveClineBuildEnv,
|
||||
type SupervisedConnectorRecord,
|
||||
} from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
@@ -24,6 +25,7 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
import open from "../utils/open";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
import { listSupervisedConnectorsViaHub } from "./connect-via-hub";
|
||||
|
||||
type DoctorIo = {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -64,6 +66,8 @@ type DoctorStatus = {
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
activeConnectors: ActiveConnectorRecord[];
|
||||
/** Undefined when the running hub cannot report supervision. */
|
||||
supervisedConnectors?: SupervisedConnectorRecord[];
|
||||
recentSpawnedProcesses: SpawnedProcessRecord[];
|
||||
};
|
||||
|
||||
@@ -72,6 +76,13 @@ type ProcessRecord = {
|
||||
command: string;
|
||||
};
|
||||
|
||||
// Container id inside a cgroup path, e.g.
|
||||
// "0::/system.slice/docker-<64-hex>.scope" (docker/containerd/podman) or
|
||||
// "/kubepods/.../<64-hex>" (kubernetes). Captures the id so two different
|
||||
// containers can be told apart, not merely "is containerised".
|
||||
const CONTAINER_CGROUP_PATTERN =
|
||||
/(?:docker[-/]|containerd[-/]|libpod[-/]|crio[-/]|lxc[-/.])([0-9a-f]{12,64})/;
|
||||
|
||||
function parsePids(raw: string): number[] {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
@@ -79,6 +90,77 @@ function parsePids(raw: string): number[] {
|
||||
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
||||
}
|
||||
|
||||
function tryReadLink(target: string): string | undefined {
|
||||
try {
|
||||
return readlinkSync(target);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readContainerCgroupId(pid: number | "self"): string | undefined {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(`/proc/${pid}/cgroup`, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return raw.match(CONTAINER_CGROUP_PATTERN)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a process belongs to a container other than our own.
|
||||
*
|
||||
* Namespace identity is the reliable signal: a containerised process has
|
||||
* different PID/mount namespaces than the host process running the scan.
|
||||
* Container ids parsed from cgroup paths are the fallback for kernels where the
|
||||
* namespace links are unreadable. Unknown on both sides means "assume ours",
|
||||
* preserving the previous behaviour rather than silently dropping processes the
|
||||
* user does want cleaned up.
|
||||
*/
|
||||
function decideForeignContainer(input: {
|
||||
platform: string;
|
||||
namespacePairs: Array<[string | undefined, string | undefined]>;
|
||||
ownContainerId: string | undefined;
|
||||
otherContainerId: string | undefined;
|
||||
}): boolean {
|
||||
// /proc/<pid>/ns exists only on Linux. Elsewhere containers run inside a VM
|
||||
// and never share a pid space with us, so there is nothing to disambiguate.
|
||||
if (input.platform !== "linux") {
|
||||
return false;
|
||||
}
|
||||
for (const [own, other] of input.namespacePairs) {
|
||||
if (own && other && own !== other) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return (
|
||||
Boolean(input.otherContainerId) &&
|
||||
input.otherContainerId !== input.ownContainerId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pid` belongs to a container other than this process's own.
|
||||
*
|
||||
* `pgrep` sees every process on the host, containers included: a Docker agent's
|
||||
* hub daemon shows up beside ours, and when the container shares our uid `kill`
|
||||
* on it succeeds. Those daemons are emphatically not stale — they belong to a
|
||||
* live agent with its own data dir — so reporting them, and killing them in
|
||||
* `doctor fix`, takes down an unrelated agent.
|
||||
*/
|
||||
function isForeignContainerPid(pid: number): boolean {
|
||||
return decideForeignContainer({
|
||||
platform: process.platform,
|
||||
namespacePairs: (["pid", "mnt"] as const).map((namespace) => [
|
||||
tryReadLink(`/proc/self/ns/${namespace}`),
|
||||
tryReadLink(`/proc/${pid}/ns/${namespace}`),
|
||||
]),
|
||||
ownContainerId: readContainerCgroupId("self"),
|
||||
otherContainerId: readContainerCgroupId(pid),
|
||||
});
|
||||
}
|
||||
|
||||
function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
@@ -108,7 +190,8 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
pid <= 0 ||
|
||||
!command ||
|
||||
pid === process.pid ||
|
||||
pid === process.ppid
|
||||
pid === process.ppid ||
|
||||
isForeignContainerPid(pid)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -354,6 +437,7 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
activeConnectors: listActiveConnectors(),
|
||||
...((await listSupervisedConnectorsSafely()) ?? {}),
|
||||
recentSpawnedProcesses: readRecentSpawnedProcesses(),
|
||||
};
|
||||
}
|
||||
@@ -378,6 +462,39 @@ function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
|
||||
return pieces.join(" | ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervision is reported by the running hub, so it is unavailable whenever
|
||||
* there is no hub or it predates supervision. Diagnostics must degrade quietly
|
||||
* rather than fail.
|
||||
*/
|
||||
async function listSupervisedConnectorsSafely(): Promise<
|
||||
{ supervisedConnectors: SupervisedConnectorRecord[] } | undefined
|
||||
> {
|
||||
try {
|
||||
const supervised = await listSupervisedConnectorsViaHub();
|
||||
return supervised ? { supervisedConnectors: supervised } : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSupervisedConnector(record: SupervisedConnectorRecord): string {
|
||||
const pieces = [
|
||||
record.channel,
|
||||
`instance=${record.instanceId}`,
|
||||
`state=${record.state}`,
|
||||
`origin=${record.origin}`,
|
||||
record.pid === undefined ? undefined : `pid=${record.pid}`,
|
||||
record.restarts > 0 ? `restarts=${record.restarts}` : undefined,
|
||||
record.nextRestartAt ? `nextRestart=${record.nextRestartAt}` : undefined,
|
||||
record.lastExitCode === undefined
|
||||
? undefined
|
||||
: `lastExit=${record.lastExitCode}`,
|
||||
record.lastError ? `error=${record.lastError}` : undefined,
|
||||
];
|
||||
return pieces.filter(Boolean).join(" | ");
|
||||
}
|
||||
|
||||
function formatActiveConnector(record: ActiveConnectorRecord): string {
|
||||
const identity =
|
||||
record.type === "telegram"
|
||||
@@ -411,6 +528,12 @@ function killPids(pids: number[]): number {
|
||||
return killed;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
decideForeignContainer,
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
formatSupervisedConnector,
|
||||
};
|
||||
|
||||
export async function runDoctorCommand(
|
||||
opts: { cwd: string; json?: boolean; fix?: boolean; verbose?: boolean },
|
||||
io: DoctorIo,
|
||||
@@ -450,6 +573,12 @@ export async function runDoctorCommand(
|
||||
writeln(`- ${c.dim}${formatActiveConnector(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (before.supervisedConnectors?.length) {
|
||||
writeln("hub-supervised connectors:");
|
||||
for (const record of before.supervisedConnectors) {
|
||||
writeln(`- ${c.dim}${formatSupervisedConnector(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (verbose && before.recentSpawnedProcesses.length > 0) {
|
||||
writeln("recent spawned processes:");
|
||||
for (const record of before.recentSpawnedProcesses) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
runMcpUninstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
@@ -56,6 +60,19 @@ describe("mcp install command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mcp-remote marketplace entries as native remote servers", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "linear",
|
||||
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "linear",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
@@ -269,3 +286,131 @@ describe("mcp install command", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp uninstall command", () => {
|
||||
let root = "";
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSettings(): string {
|
||||
const settingsPath = join(root, "cline_mcp_settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
keep: {
|
||||
transport: { type: "stdio", command: "node" },
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
customTopLevelKey: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
function readSettings(settingsPath: string): Record<string, unknown> & {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
} {
|
||||
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { mcpServers?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("uninstalls the requested server and reports success", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "docs",
|
||||
settingsPath,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
|
||||
const written = readSettings(settingsPath);
|
||||
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
|
||||
expect(written.mcpServers?.keep).toEqual({
|
||||
transport: { type: "stdio", command: "node" },
|
||||
disabled: true,
|
||||
});
|
||||
expect(written.customTopLevelKey).toBe(true);
|
||||
});
|
||||
|
||||
it("prints uninstall JSON with --json", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "docs",
|
||||
settingsPath,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
|
||||
name: "docs",
|
||||
status: "uninstalled",
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports an error and leaves settings intact for an unknown server", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const before = readFileSync(settingsPath, "utf8");
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "missing",
|
||||
settingsPath,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
'MCP server "missing" is not installed.',
|
||||
);
|
||||
expect(writeln).not.toHaveBeenCalled();
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("rejects a blank name without rewriting settings", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const before = readFileSync(settingsPath, "utf8");
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: " ",
|
||||
settingsPath,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
buildMcpInstallTransport as buildCoreMcpInstallTransport,
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
type McpUninstallOptions as CoreMcpUninstallOptions,
|
||||
type McpUninstallResult as CoreMcpUninstallResult,
|
||||
uninstallMcpServer,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
@@ -28,39 +32,6 @@ export interface McpInstallDirectResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
@@ -73,36 +44,20 @@ export function buildMcpInstallDefaults(options: {
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
const { name, transport } = buildCoreMcpInstallTransport(options);
|
||||
if (transport.type === "stdio") {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
type: transport.type,
|
||||
command: [transport.command, ...(transport.args ?? [])]
|
||||
.map(quoteCommandArg)
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
type: transport.type,
|
||||
url: transport.url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,3 +113,42 @@ export async function runMcpInstallCommand(
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
|
||||
io?: McpCommandIo;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
|
||||
|
||||
export function uninstallMcpServerDirect(
|
||||
options: McpUninstallOptions,
|
||||
): McpUninstallDirectResult {
|
||||
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMcpUninstallCommand(
|
||||
options: McpUninstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const name = options.name?.trim() ?? "";
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const result = uninstallMcpServerDirect({ ...options, name });
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
providers: {} as Record<string, { provider?: string; model?: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
return mockProviderSettings.providers[providerId];
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
parseHubEndpointOverride: (rawAddress: string | undefined) => {
|
||||
@@ -47,6 +65,8 @@ async function runScheduleCommand(
|
||||
describe("runScheduleCommand list output", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it('prints "No schedules found." for empty non-json list output', async () => {
|
||||
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runScheduleCommand create delivery metadata", () => {
|
||||
describe("runScheduleCommand create", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("uses the last used provider and model when both flags are omitted", async () => {
|
||||
mockProviderSettings.lastUsed = {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: (text?: string) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an explicit provider with that provider's configured model", async () => {
|
||||
mockProviderSettings.lastUsed = {
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
};
|
||||
mockProviderSettings.providers.anthropic = {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when an explicit provider has no configured model and no model flag", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errors).toEqual([
|
||||
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
|
||||
]);
|
||||
expect(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
|
||||
describe("runScheduleCommand import", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("preserves exported modelSelection providerId/modelId values", async () => {
|
||||
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
|
||||
describe("runScheduleCommand export", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("writes JSON content to the --to file path", async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
registerScheduleImportCommand,
|
||||
registerScheduleUpdateCommand,
|
||||
} from "./import-export";
|
||||
import { resolveScheduleModelSelection } from "./model-selection";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
export function registerScheduleCommands(
|
||||
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
|
||||
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--model <model>", "Model to use")
|
||||
.option("--provider <id>", "Provider ID")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
.option("--timeout <seconds>", "Timeout in seconds");
|
||||
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
|
||||
parseJsonObjectFlag(opts.metadataJson),
|
||||
opts,
|
||||
);
|
||||
const modelSelection = resolveScheduleModelSelection({
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
});
|
||||
const created = await client.createSchedule({
|
||||
name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
provider: modelSelection.provider,
|
||||
model: modelSelection.model,
|
||||
mode: parseMode(opts.mode) ?? "yolo",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -18,8 +17,13 @@ import {
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
import { resolveScheduleModelSelection } from "./model-selection";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
!Array.isArray(parsed.modelSelection)
|
||||
? (parsed.modelSelection as Record<string, unknown>)
|
||||
: undefined;
|
||||
const provider = String(
|
||||
modelSelection?.providerId ??
|
||||
parsed.providerId ??
|
||||
parsed.provider ??
|
||||
"cline",
|
||||
).trim();
|
||||
const model = String(
|
||||
modelSelection?.modelId ??
|
||||
parsed.modelId ??
|
||||
parsed.model ??
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
).trim();
|
||||
return { provider, model };
|
||||
return resolveScheduleModelSelection({
|
||||
provider:
|
||||
stringValue(modelSelection?.providerId) ??
|
||||
stringValue(parsed.providerId) ??
|
||||
stringValue(parsed.provider),
|
||||
model:
|
||||
stringValue(modelSelection?.modelId) ??
|
||||
stringValue(parsed.modelId) ??
|
||||
stringValue(parsed.model),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerScheduleExportCommand(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
|
||||
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
|
||||
|
||||
interface ProviderSettingsReader {
|
||||
getLastUsedProviderSettings(): ProviderSettings | undefined;
|
||||
getProviderSettings(providerId: string): ProviderSettings | undefined;
|
||||
}
|
||||
|
||||
function trimToUndefined(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveScheduleModelSelection(
|
||||
options: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
providerSettingsManager?: ProviderSettingsReader,
|
||||
): { provider: string; model: string } {
|
||||
const explicitProvider = trimToUndefined(options.provider);
|
||||
const explicitModel = trimToUndefined(options.model);
|
||||
if (explicitProvider && explicitModel) {
|
||||
return { provider: explicitProvider, model: explicitModel };
|
||||
}
|
||||
|
||||
const manager = providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const lastUsedSettings = manager.getLastUsedProviderSettings();
|
||||
const provider =
|
||||
explicitProvider ??
|
||||
trimToUndefined(lastUsedSettings?.provider) ??
|
||||
DEFAULT_SCHEDULE_PROVIDER;
|
||||
const selectedProviderSettings = explicitProvider
|
||||
? manager.getProviderSettings(provider)
|
||||
: lastUsedSettings;
|
||||
const model =
|
||||
explicitModel ??
|
||||
trimToUndefined(selectedProviderSettings?.model) ??
|
||||
(provider === DEFAULT_SCHEDULE_PROVIDER
|
||||
? CLINE_DEFAULT_MODEL_ID
|
||||
: undefined);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
|
||||
import type { ChatStartSessionRequest } from "@cline/core";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -729,60 +731,69 @@ class DiscordConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Discord bot username label")
|
||||
.option("--application-id <id>", "Discord application id")
|
||||
.option("--app-id <id>", "Alias for --application-id")
|
||||
.option("--bot-token <token>", "Discord bot token")
|
||||
.option("--token <token>", "Alias for --bot-token")
|
||||
.option("--public-key <key>", "Discord application public key")
|
||||
.option(
|
||||
"--owner-user-id <id>",
|
||||
"Discord user id that should be marked as connector owner",
|
||||
)
|
||||
.option("--ignore-bot-authors", "Ignore messages from other Discord bots")
|
||||
.option(
|
||||
"--mention-role-ids <ids>",
|
||||
"Comma-separated role IDs that should trigger mention handlers",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Discord sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for Discord interactions webhook",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" DISCORD_APPLICATION_ID Discord application id",
|
||||
" DISCORD_BOT_TOKEN Discord bot token",
|
||||
" DISCORD_PUBLIC_KEY Discord application public key",
|
||||
" DISCORD_OWNER_USER_ID Optional connector owner user id",
|
||||
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
|
||||
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Discord bot username label")
|
||||
.option("--application-id <id>", "Discord application id")
|
||||
.option("--app-id <id>", "Alias for --application-id")
|
||||
.option("--bot-token <token>", "Discord bot token")
|
||||
.option("--token <token>", "Alias for --bot-token")
|
||||
.option("--public-key <key>", "Discord application public key")
|
||||
.option(
|
||||
"--owner-user-id <id>",
|
||||
"Discord user id that should be marked as connector owner",
|
||||
)
|
||||
.option(
|
||||
"--ignore-bot-authors",
|
||||
"Ignore messages from other Discord bots",
|
||||
)
|
||||
.option(
|
||||
"--mention-role-ids <ids>",
|
||||
"Comma-separated role IDs that should trigger mention handlers",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Discord sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for Discord interactions webhook",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" DISCORD_APPLICATION_ID Discord application id",
|
||||
" DISCORD_BOT_TOKEN Discord bot token",
|
||||
" DISCORD_PUBLIC_KEY Discord application public key",
|
||||
" DISCORD_OWNER_USER_ID Optional connector owner user id",
|
||||
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
|
||||
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectDiscordOptions {
|
||||
@@ -804,6 +815,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -856,7 +868,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -973,6 +985,12 @@ class DiscordConnector extends ConnectorBase<
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
): string | undefined {
|
||||
return options.applicationId;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
rawArgs: string[],
|
||||
@@ -1119,7 +1137,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
isSubscribedThreadMessage?: boolean;
|
||||
},
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -235,48 +236,54 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Google Chat bot username label")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Google Chat sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.option(
|
||||
"--pubsub-topic <topic>",
|
||||
"Optional Pub/Sub topic for all-message events",
|
||||
)
|
||||
.option("--impersonate-user <email>", "Optional delegation user email")
|
||||
.option("--use-adc", "Use Google Application Default Credentials")
|
||||
.option("--credentials-json <json>", "Service account credentials JSON")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" GOOGLE_CHAT_CREDENTIALS Service account JSON",
|
||||
" GOOGLE_CHAT_USE_ADC=true Use Application Default Credentials",
|
||||
" GOOGLE_CHAT_PUBSUB_TOPIC Optional Pub/Sub topic",
|
||||
" GOOGLE_CHAT_IMPERSONATE_USER Optional delegation user",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Google Chat bot username label")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Google Chat sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.option(
|
||||
"--pubsub-topic <topic>",
|
||||
"Optional Pub/Sub topic for all-message events",
|
||||
)
|
||||
.option("--impersonate-user <email>", "Optional delegation user email")
|
||||
.option("--use-adc", "Use Google Application Default Credentials")
|
||||
.option("--credentials-json <json>", "Service account credentials JSON")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" GOOGLE_CHAT_CREDENTIALS Service account JSON",
|
||||
" GOOGLE_CHAT_USE_ADC=true Use Application Default Credentials",
|
||||
" GOOGLE_CHAT_PUBSUB_TOPIC Optional Pub/Sub topic",
|
||||
" GOOGLE_CHAT_IMPERSONATE_USER Optional delegation user",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectGoogleChatOptions {
|
||||
@@ -290,6 +297,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -316,7 +324,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -463,6 +471,12 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
}
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
): string | undefined {
|
||||
return options.userName;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
rawArgs: string[],
|
||||
@@ -614,7 +628,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
thread: Thread<GoogleChatThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -290,48 +291,54 @@ class LinearConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Linear bot display name")
|
||||
.option("--api-key <key>", "Linear personal API key")
|
||||
.option("--client-id <id>", "Linear OAuth client id")
|
||||
.option("--client-secret <secret>", "Linear OAuth client secret")
|
||||
.option("--access-token <token>", "Pre-obtained Linear access token")
|
||||
.option("--webhook-secret <secret>", "Linear webhook signing secret")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--provider-api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Linear sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" LINEAR_API_KEY Personal API key",
|
||||
" LINEAR_CLIENT_ID OAuth client id",
|
||||
" LINEAR_CLIENT_SECRET OAuth client secret",
|
||||
" LINEAR_ACCESS_TOKEN Pre-obtained access token",
|
||||
" LINEAR_WEBHOOK_SECRET Webhook signing secret",
|
||||
" LINEAR_BOT_USERNAME Bot display name (default: linear-bot)",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Linear bot display name")
|
||||
.option("--api-key <key>", "Linear personal API key")
|
||||
.option("--client-id <id>", "Linear OAuth client id")
|
||||
.option("--client-secret <secret>", "Linear OAuth client secret")
|
||||
.option("--access-token <token>", "Pre-obtained Linear access token")
|
||||
.option("--webhook-secret <secret>", "Linear webhook signing secret")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--provider-api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Linear sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" LINEAR_API_KEY Personal API key",
|
||||
" LINEAR_CLIENT_ID OAuth client id",
|
||||
" LINEAR_CLIENT_SECRET OAuth client secret",
|
||||
" LINEAR_ACCESS_TOKEN Pre-obtained access token",
|
||||
" LINEAR_WEBHOOK_SECRET Webhook signing secret",
|
||||
" LINEAR_BOT_USERNAME Bot display name (default: linear-bot)",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectLinearOptions {
|
||||
@@ -350,6 +357,7 @@ class LinearConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -396,7 +404,7 @@ class LinearConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -488,6 +496,12 @@ class LinearConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectLinearOptions,
|
||||
): string | undefined {
|
||||
return options.userName;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectLinearOptions,
|
||||
rawArgs: string[],
|
||||
@@ -637,7 +651,7 @@ class LinearConnector extends ConnectorBase<
|
||||
thread: Thread<LinearThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
|
||||
@@ -433,3 +433,53 @@ describe("slack binding lookup", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slack legacy connector state", () => {
|
||||
it("stops a live connector recorded by a pre-claim state file (no claimId)", async () => {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
const previousDataDir = process.env.CLINE_DATA_DIR;
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "slack-legacy-state-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
try {
|
||||
const stateDir = join(dataDir, "connectors", "slack");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateDir, "mybot.json"),
|
||||
JSON.stringify({
|
||||
userName: "mybot",
|
||||
connectionMode: "socket",
|
||||
pid: child.pid,
|
||||
// Unreachable on purpose: session cleanup falls back to
|
||||
// local storage inside the isolated data dir.
|
||||
rpcAddress: "ws://127.0.0.1:1/hub",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const io = { writeln: () => {}, writeErr: () => {} };
|
||||
const result = await slackConnector.stopInstance?.("mybot", io);
|
||||
|
||||
expect(result?.stoppedProcesses).toBe(1);
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = previousDataDir;
|
||||
}
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
enqueueThreadTurn,
|
||||
startConnectorWebhookServer,
|
||||
} from "../chat-runtime";
|
||||
import { isProcessRunning } from "../common";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
|
||||
import {
|
||||
type ActiveConnectorTurn,
|
||||
handleConnectorUserTurn,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
writeBindings,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
@@ -64,19 +65,13 @@ import type {
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
getConnectorFirstContactMessage,
|
||||
getConnectorSystemPrompt,
|
||||
getConnectorSystemRules,
|
||||
} from "./prompts";
|
||||
import { getConnectorSystemPrompt, getConnectorSystemRules } from "./prompts";
|
||||
|
||||
const SLACK_SYSTEM_RULES = getConnectorSystemRules(
|
||||
"Slack",
|
||||
"You can respond to user messages in threads and DMs, and you can use tools according to user's requests and your capabilities.",
|
||||
);
|
||||
|
||||
const SLACK_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
|
||||
|
||||
type SlackThreadState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
@@ -501,62 +496,68 @@ class SlackConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Slack bot username label")
|
||||
.option(
|
||||
"--bot-token <token>",
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
"--encryption-key <key>",
|
||||
"Base64 32-byte key for encrypted installations",
|
||||
)
|
||||
.option(
|
||||
"--installation-key-prefix <prefix>",
|
||||
"Override stored installation key prefix",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Slack sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for webhooks and OAuth callback",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Slack bot username label")
|
||||
.option(
|
||||
"--bot-token <token>",
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
"--encryption-key <key>",
|
||||
"Base64 32-byte key for encrypted installations",
|
||||
)
|
||||
.option(
|
||||
"--installation-key-prefix <prefix>",
|
||||
"Override stored installation key prefix",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Slack sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for webhooks and OAuth callback",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectSlackOptions {
|
||||
@@ -577,6 +578,7 @@ class SlackConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -643,7 +645,7 @@ class SlackConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -682,6 +684,9 @@ class SlackConnector extends ConnectorBase<
|
||||
Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
// claimId is optional: state files written by older CLI
|
||||
// versions predate claiming and must stay manageable
|
||||
// (already-running detection, status, stop).
|
||||
typeof (value as SlackConnectorState).pid === "number" &&
|
||||
typeof (value as SlackConnectorState).userName === "string",
|
||||
),
|
||||
@@ -733,6 +738,12 @@ class SlackConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectSlackOptions,
|
||||
): string | undefined {
|
||||
return options.userName;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectSlackOptions,
|
||||
rawArgs: string[],
|
||||
@@ -743,14 +754,18 @@ class SlackConnector extends ConnectorBase<
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const stateStorePath = this.resolveStateStorePath(options.userName);
|
||||
const staleState = this.removeStaleState(
|
||||
statePath,
|
||||
(path) => this.readConnectorState(path),
|
||||
(state) => state.pid,
|
||||
);
|
||||
const existingState = this.readConnectorState(statePath);
|
||||
const staleState =
|
||||
existingState && !isProcessRunning(existingState.pid)
|
||||
? existingState
|
||||
: undefined;
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
}
|
||||
const formatAlreadyRunning = (state: SlackConnectorState) =>
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
@@ -759,10 +774,7 @@ class SlackConnector extends ConnectorBase<
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatAlreadyRunningMessage: formatAlreadyRunning,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
@@ -773,6 +785,34 @@ class SlackConnector extends ConnectorBase<
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
// Foreground / detached-child path: exclusively claim the instance before
|
||||
// opening Slack socket-mode so a second process cannot share the token.
|
||||
const startedAt = new Date().toISOString();
|
||||
const claim = this.claimConnectorInstance({
|
||||
statePath,
|
||||
createState: (claimId) => ({
|
||||
claimId,
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
rpcAddress: "pending",
|
||||
startedAt,
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
}),
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
getPid: (state) => state.pid,
|
||||
});
|
||||
if (!claim.claimed) {
|
||||
io.writeln(
|
||||
claim.running
|
||||
? formatAlreadyRunning(claim.running)
|
||||
: `[slack] connector already running for user=${options.userName}`,
|
||||
);
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "slack-connect",
|
||||
@@ -860,6 +900,7 @@ class SlackConnector extends ConnectorBase<
|
||||
});
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
claimId: claim.claimId,
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
@@ -867,7 +908,7 @@ class SlackConnector extends ConnectorBase<
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
startedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
@@ -892,7 +933,7 @@ class SlackConnector extends ConnectorBase<
|
||||
bindingsPath,
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
@@ -919,7 +960,6 @@ class SlackConnector extends ConnectorBase<
|
||||
hookCommand: options.hookCommand,
|
||||
systemRules: SLACK_SYSTEM_RULES,
|
||||
errorLabel: "Slack",
|
||||
firstContactMessage: SLACK_FIRST_CONTACT_MESSAGE,
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import type { ConnectTelegramOptions } from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
|
||||
import { handleConnectorUserTurn } from "../connector-host";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -468,3 +469,322 @@ describe("telegram binding lookup", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
type SlashTestState = Record<string, unknown>;
|
||||
|
||||
function createSlashTestThread(input: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
isDM: boolean;
|
||||
initialState?: SlashTestState;
|
||||
}) {
|
||||
let state: SlashTestState = { ...(input.initialState ?? {}) };
|
||||
const posts: unknown[] = [];
|
||||
let subscribed = false;
|
||||
const thread = {
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
get state() {
|
||||
return Promise.resolve(state);
|
||||
},
|
||||
async setState(nextState: SlashTestState) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
async subscribe() {
|
||||
subscribed = true;
|
||||
},
|
||||
async post(message: unknown) {
|
||||
posts.push(message);
|
||||
const sentMessage = {
|
||||
edit: async (nextMessage: unknown) => {
|
||||
posts.push(nextMessage);
|
||||
return sentMessage;
|
||||
},
|
||||
delete: async () => undefined,
|
||||
};
|
||||
return sentMessage;
|
||||
},
|
||||
async startTyping() {},
|
||||
toJSON() {
|
||||
return {
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
state,
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
thread,
|
||||
posts,
|
||||
getState: () => state,
|
||||
isSubscribed: () => subscribed,
|
||||
};
|
||||
}
|
||||
|
||||
function slashTestStartRequest() {
|
||||
return {
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
systemPrompt: "system",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
mode: "act",
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegram slash command delivery", () => {
|
||||
it("receives bot_command updates intercepted by the telegram chat library", async () => {
|
||||
// The Telegram Bot API tags any leading-slash message with a
|
||||
// `bot_command` entity, and @chat-adapter/telegram diverts those
|
||||
// updates away from the mention/subscribed-message handlers. This
|
||||
// pins the delivery contract: an intercepted update must still reach
|
||||
// the connector turn handler through the slash-command path.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, result: {} }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { createTelegramAdapter } = await import("@chat-adapter/telegram");
|
||||
const { Chat, ConsoleLogger } = await import("chat");
|
||||
const { InMemoryStateAdapter } = await import("../stores/memory-state");
|
||||
const telegram = createTelegramAdapter({
|
||||
mode: "polling",
|
||||
botToken: "123456:TEST-TOKEN",
|
||||
userName: "test_bot",
|
||||
logger: new ConsoleLogger("error", "telegram-slash-test"),
|
||||
});
|
||||
const bot = new Chat({
|
||||
userName: "test_bot",
|
||||
adapters: { telegram },
|
||||
state: new InMemoryStateAdapter(),
|
||||
logger: new ConsoleLogger("error", "telegram-slash-test"),
|
||||
});
|
||||
// bot.initialize() assigns this reference before polling starts; set
|
||||
// it directly to avoid the real getMe/long-polling network calls.
|
||||
(telegram as unknown as { chat: unknown }).chat = bot;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const turns: Array<{ threadId: string; isDM: boolean; text: string }> = [];
|
||||
bot.onSlashCommand(
|
||||
__test__.createTelegramSlashCommandHandler({
|
||||
bot,
|
||||
bindingsPath: join(dir, "threads.json"),
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: async (thread, text) => {
|
||||
turns.push({ threadId: thread.id, isDM: thread.isDM, text });
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const completions: Promise<unknown>[] = [];
|
||||
(
|
||||
telegram as unknown as {
|
||||
processUpdate: (update: unknown, options?: unknown) => void;
|
||||
}
|
||||
).processUpdate(
|
||||
{
|
||||
update_id: 1,
|
||||
message: {
|
||||
message_id: 42,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: { id: 555, type: "private" },
|
||||
from: {
|
||||
id: 999,
|
||||
is_bot: false,
|
||||
first_name: "Alice",
|
||||
username: "alice",
|
||||
},
|
||||
text: "/clear",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 6 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
waitUntil: (task: Promise<unknown>) =>
|
||||
completions.push(task.catch(() => undefined)),
|
||||
},
|
||||
);
|
||||
await Promise.all(completions);
|
||||
await vi.waitFor(() => {
|
||||
expect(turns).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(turns[0]).toEqual({
|
||||
threadId: "telegram:555",
|
||||
isDM: true,
|
||||
text: "/clear",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes intercepted slash commands into the connector turn handler", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, isSubscribed, getState } = createSlashTestThread({
|
||||
id: "telegram:12345",
|
||||
channelId: "telegram:12345",
|
||||
isDM: true,
|
||||
});
|
||||
const botThread = vi.fn(() => thread);
|
||||
const handleTurn = vi.fn(async () => undefined);
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: botThread } as never,
|
||||
bindingsPath,
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: handleTurn as never,
|
||||
});
|
||||
|
||||
await handler({
|
||||
channel: { id: "telegram:12345" },
|
||||
command: "/clear",
|
||||
text: "",
|
||||
raw: {
|
||||
message_id: 7,
|
||||
chat: { id: 12345, type: "private" },
|
||||
from: { id: 999, username: "alice", first_name: "Alice" },
|
||||
text: "/clear",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 6 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(botThread).toHaveBeenCalledWith("telegram:12345");
|
||||
expect(isSubscribed()).toBe(true);
|
||||
expect(handleTurn).toHaveBeenCalledWith(thread, "/clear");
|
||||
expect(getState().participantKey).toBe("telegram:id:999");
|
||||
});
|
||||
|
||||
it("preserves group-chat bot addressing in the forwarded command text", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const { thread } = createSlashTestThread({
|
||||
id: "telegram:-100200",
|
||||
channelId: "telegram:-100200",
|
||||
isDM: false,
|
||||
});
|
||||
const handleTurn = vi.fn(async () => undefined);
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: () => thread } as never,
|
||||
bindingsPath: join(dir, "threads.json"),
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: handleTurn as never,
|
||||
});
|
||||
|
||||
// The chat adapter strips "@test_bot" into command targeting before
|
||||
// invoking slash handlers; the raw message text keeps it so the
|
||||
// connector host can enforce group addressing rules.
|
||||
await handler({
|
||||
channel: { id: "telegram:-100200" },
|
||||
command: "/tools",
|
||||
text: "on",
|
||||
raw: {
|
||||
message_id: 8,
|
||||
chat: { id: -100200, type: "supergroup" },
|
||||
from: { id: 999, username: "alice" },
|
||||
text: "/tools@test_bot on",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 15 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleTurn).toHaveBeenCalledWith(thread, "/tools@test_bot on");
|
||||
});
|
||||
|
||||
it("falls back to the parsed command when the raw payload has no text", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const { thread } = createSlashTestThread({
|
||||
id: "telegram:12345",
|
||||
channelId: "telegram:12345",
|
||||
isDM: true,
|
||||
});
|
||||
const handleTurn = vi.fn(async () => undefined);
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: () => thread } as never,
|
||||
bindingsPath: join(dir, "threads.json"),
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: handleTurn as never,
|
||||
});
|
||||
|
||||
await handler({
|
||||
channel: { id: "telegram:12345" },
|
||||
command: "/cwd",
|
||||
text: "/tmp",
|
||||
raw: undefined,
|
||||
});
|
||||
|
||||
expect(handleTurn).toHaveBeenCalledWith(thread, "/cwd /tmp");
|
||||
});
|
||||
|
||||
it("delivers intercepted slash commands to the chat command host", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts } = createSlashTestThread({
|
||||
id: "telegram:777",
|
||||
channelId: "telegram:777",
|
||||
isDM: true,
|
||||
initialState: {
|
||||
sessionId: "session-1",
|
||||
participantKey: "telegram:id:999",
|
||||
participantLabel: "alice",
|
||||
welcomeSentAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const stopRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const baseStartRequest = slashTestStartRequest();
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: () => thread } as never,
|
||||
bindingsPath,
|
||||
baseStartRequest: baseStartRequest as never,
|
||||
handleTurn: (async (
|
||||
turnThread: typeof thread,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await handleConnectorUserTurn({
|
||||
thread: turnThread as never,
|
||||
text,
|
||||
client: { stopRuntimeSession, deleteSession } as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "telegram",
|
||||
botUserName: "test_bot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Telegram",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
});
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await handler({
|
||||
channel: { id: "telegram:777" },
|
||||
command: "/clear",
|
||||
text: "",
|
||||
raw: {
|
||||
message_id: 9,
|
||||
chat: { id: 777, type: "private" },
|
||||
from: { id: 999, username: "alice" },
|
||||
text: "/clear",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 6 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteSession).toHaveBeenCalledWith("session-1", true);
|
||||
expect(posts).toContainEqual({ raw: "Started a fresh session." });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -266,6 +267,55 @@ async function persistTelegramThreadContext(input: {
|
||||
);
|
||||
}
|
||||
|
||||
type TelegramSlashCommandEvent = {
|
||||
channel: { id: string };
|
||||
command: string;
|
||||
text: string;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Telegram chat adapter intercepts any message whose leading entity is a
|
||||
* `bot_command` and delivers it to slash-command handlers instead of the
|
||||
* normal mention/subscribed-message handlers. Without a registered handler
|
||||
* the command is consumed and dropped, so connector commands like /clear
|
||||
* never reach the chat command host. Rebuild the originating chat thread and
|
||||
* forward the original message text (preserving any `@bot` addressing used
|
||||
* in group chats) into the same turn pipeline as regular messages.
|
||||
*/
|
||||
function createTelegramSlashCommandHandler(input: {
|
||||
bot: Pick<Chat, "thread">;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
handleTurn: (
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => Promise<void>;
|
||||
}): (event: TelegramSlashCommandEvent) => Promise<void> {
|
||||
return async (event) => {
|
||||
const raw = asRecord(event.raw);
|
||||
const commandText =
|
||||
readString(raw?.text) ??
|
||||
readString(raw?.caption) ??
|
||||
[event.command.trim(), event.text.trim()].filter(Boolean).join(" ");
|
||||
if (!commandText) {
|
||||
return;
|
||||
}
|
||||
const thread = input.bot.thread(
|
||||
event.channel.id,
|
||||
) as Thread<TelegramThreadState>;
|
||||
await thread.subscribe();
|
||||
await persistTelegramThreadContext({
|
||||
thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
rawMessage: event.raw,
|
||||
errorLabel: "Telegram",
|
||||
});
|
||||
await input.handleTurn(thread, commandText);
|
||||
};
|
||||
}
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: HubSessionClient;
|
||||
@@ -417,47 +467,53 @@ class TelegramConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
|
||||
.option(
|
||||
"-m, --bot-username <name>",
|
||||
"Telegram bot username; fetched from token if omitted",
|
||||
)
|
||||
.option("-k, --bot-token <token>", "Telegram bot token")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
|
||||
.option(
|
||||
"-m, --bot-username <name>",
|
||||
"Telegram bot username; fetched from token if omitted",
|
||||
)
|
||||
.option("-k, --bot-token <token>", "Telegram bot token")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectTelegramOptions {
|
||||
@@ -631,6 +687,17 @@ class TelegramConnector extends ConnectorBase<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only knowable up front when `--bot-username` was supplied; otherwise the
|
||||
* username is resolved from Telegram's API during startup, and the caller
|
||||
* has to start this connector locally instead of through the hub.
|
||||
*/
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectTelegramOptions,
|
||||
): string | undefined {
|
||||
return options.botUsername;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
inputOptions: ConnectTelegramOptions,
|
||||
rawArgs: string[],
|
||||
@@ -816,7 +883,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
@@ -1007,6 +1074,15 @@ class TelegramConnector extends ConnectorBase<
|
||||
await handleTurn(thread, message.text);
|
||||
});
|
||||
|
||||
bot.onSlashCommand(
|
||||
createTelegramSlashCommandHandler({
|
||||
bot,
|
||||
bindingsPath,
|
||||
baseStartRequest: startRequest,
|
||||
handleTurn,
|
||||
}),
|
||||
);
|
||||
|
||||
await bot.initialize();
|
||||
const stopTaskUpdateStream =
|
||||
startConnectorTaskUpdateRelay<TelegramThreadState>({
|
||||
@@ -1151,6 +1227,7 @@ export const telegramConnector: ConnectCommandDefinition =
|
||||
new TelegramConnector();
|
||||
|
||||
export const __test__ = {
|
||||
createTelegramSlashCommandHandler,
|
||||
fetchTelegramBotUsername,
|
||||
readTelegramBotId,
|
||||
resolveTelegramBotUsername,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discordConnector } from "./discord";
|
||||
import { gchatConnector } from "./gchat";
|
||||
import { linearConnector } from "./linear";
|
||||
import { slackConnector } from "./slack";
|
||||
import { telegramConnector } from "./telegram";
|
||||
import { whatsappConnector } from "./whatsapp";
|
||||
|
||||
/**
|
||||
* Every connector runs with tools enabled unless the operator opts out, so this
|
||||
* has to hold for all of them at once rather than per adapter — the whole point
|
||||
* is that there is no adapter where the default is different.
|
||||
*/
|
||||
const connectors: Array<{
|
||||
name: string;
|
||||
connector: unknown;
|
||||
/** Minimal arguments that parse for this adapter. */
|
||||
baseArgs: string[];
|
||||
}> = [
|
||||
{
|
||||
name: "slack",
|
||||
connector: slackConnector,
|
||||
baseArgs: ["--user-name", "bot"],
|
||||
},
|
||||
{
|
||||
name: "discord",
|
||||
connector: discordConnector,
|
||||
baseArgs: ["--application-id", "app-1", "--bot-token", "token"],
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
connector: linearConnector,
|
||||
baseArgs: [
|
||||
"--user-name",
|
||||
"bot",
|
||||
"--api-key",
|
||||
"key",
|
||||
"--webhook-secret",
|
||||
"secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
connector: gchatConnector,
|
||||
baseArgs: ["--user-name", "bot"],
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
connector: whatsappConnector,
|
||||
baseArgs: ["--user-name", "bot", "--phone-number-id", "123"],
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
connector: telegramConnector,
|
||||
baseArgs: ["--bot-token", "123:token"],
|
||||
},
|
||||
];
|
||||
|
||||
function parse(
|
||||
connector: unknown,
|
||||
rawArgs: string[],
|
||||
): { enableTools: boolean } {
|
||||
return (
|
||||
connector as {
|
||||
parseArgs(rawArgs: string[]): { enableTools: boolean };
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
}
|
||||
|
||||
describe("connector tools default", () => {
|
||||
for (const { name, connector, baseArgs } of connectors) {
|
||||
it(`${name}: enables tools when nothing is passed`, () => {
|
||||
expect(parse(connector, baseArgs).enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it(`${name}: disables tools with --no-tools`, () => {
|
||||
expect(parse(connector, [...baseArgs, "--no-tools"]).enableTools).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${name}: an explicit --no-tools beats --enable-tools`, () => {
|
||||
// Ambiguous input resolves to the safer answer.
|
||||
expect(
|
||||
parse(connector, [...baseArgs, "--enable-tools", "--no-tools"])
|
||||
.enableTools,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps accepting --enable-tools so existing invocations still parse", () => {
|
||||
// Persisted autostart arguments and deployed scripts carry this flag; it is
|
||||
// redundant now but must not become an unknown-option error.
|
||||
for (const { connector, baseArgs } of connectors) {
|
||||
expect(
|
||||
parse(connector, [...baseArgs, "--enable-tools"]).enableTools,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -273,47 +274,53 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "WhatsApp bot username label")
|
||||
.option("--phone-number-id <id>", "WhatsApp Business phone number id")
|
||||
.option("--access-token <token>", "Meta access token")
|
||||
.option("--app-secret <secret>", "Meta app secret")
|
||||
.option("--verify-token <token>", "Webhook verify token")
|
||||
.option("--api-version <version>", "Graph API version", "v21.0")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for WhatsApp sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" WHATSAPP_ACCESS_TOKEN Meta access token",
|
||||
" WHATSAPP_APP_SECRET Meta app secret",
|
||||
" WHATSAPP_PHONE_NUMBER_ID WhatsApp Business phone number id",
|
||||
" WHATSAPP_VERIFY_TOKEN Webhook verification token",
|
||||
" WHATSAPP_BOT_USERNAME Bot username label",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "WhatsApp bot username label")
|
||||
.option("--phone-number-id <id>", "WhatsApp Business phone number id")
|
||||
.option("--access-token <token>", "Meta access token")
|
||||
.option("--app-secret <secret>", "Meta app secret")
|
||||
.option("--verify-token <token>", "Webhook verify token")
|
||||
.option("--api-version <version>", "Graph API version", "v21.0")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for WhatsApp sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" WHATSAPP_ACCESS_TOKEN Meta access token",
|
||||
" WHATSAPP_APP_SECRET Meta app secret",
|
||||
" WHATSAPP_PHONE_NUMBER_ID WhatsApp Business phone number id",
|
||||
" WHATSAPP_VERIFY_TOKEN Webhook verification token",
|
||||
" WHATSAPP_BOT_USERNAME Bot username label",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectWhatsAppOptions {
|
||||
@@ -332,6 +339,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -364,7 +372,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -455,6 +463,15 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectWhatsAppOptions,
|
||||
): string | undefined {
|
||||
return resolveInstanceKey({
|
||||
phoneNumberId: options.phoneNumberId,
|
||||
userName: options.userName,
|
||||
});
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectWhatsAppOptions,
|
||||
rawArgs: string[],
|
||||
@@ -608,7 +625,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
thread: Thread<WhatsAppThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
|
||||
@@ -116,7 +116,29 @@ describe("ConnectorBase background launch", () => {
|
||||
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: child exited before becoming ready",
|
||||
expect.stringContaining(
|
||||
"launch failed: child exited before becoming ready",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("points at the child log so a startup failure is diagnosable", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
mocks.isProcessRunning.mockReturnValue(false);
|
||||
|
||||
await new TestConnector().runBackground(io);
|
||||
|
||||
const [message] = vi.mocked(io.writeErr).mock.calls[0] ?? [];
|
||||
expect(message).toContain("logs/connectors/test/test-connector.log");
|
||||
expect(mocks.spawnDetachedConnector).toHaveBeenCalledWith(
|
||||
["connect", "test"],
|
||||
["--token", "secret"],
|
||||
"CLINE_TEST_CONNECT_CHILD",
|
||||
expect.objectContaining({
|
||||
logPath: expect.stringContaining(
|
||||
"logs/connectors/test/test-connector.log",
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,7 +151,7 @@ describe("ConnectorBase background launch", () => {
|
||||
|
||||
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: timed out after 0ms",
|
||||
expect.stringContaining("launch failed: timed out after 0ms"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { isSupervisedConnectorProcess } from "@cline/shared";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
resolveConnectorDebugLogPath,
|
||||
spawnDetachedConnector,
|
||||
terminateProcess,
|
||||
tryClaimConnectorStateFile,
|
||||
writeJsonFile,
|
||||
} from "./common";
|
||||
import type {
|
||||
@@ -21,6 +31,65 @@ import type {
|
||||
const SHOW_HELP_ERROR = "__SHOW_HELP__";
|
||||
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const CONNECTOR_STARTUP_POLL_MS = 100;
|
||||
const CHILD_LOG_TAIL_BYTES = 8_192;
|
||||
const CHILD_LOG_TAIL_LINES = 3;
|
||||
const ESC = String.fromCharCode(27);
|
||||
const BEL = String.fromCharCode(7);
|
||||
const ANSI_SEQUENCE_PATTERN = new RegExp(
|
||||
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
|
||||
"g",
|
||||
);
|
||||
|
||||
function stripAnsiCodes(text: string): string {
|
||||
return text.replace(ANSI_SEQUENCE_PATTERN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface why a detached connector child died. The child's own error is the
|
||||
* useful part; the parent only knows that it exited, so quote the tail of the
|
||||
* child's log and point at the file for the rest.
|
||||
*/
|
||||
function formatChildLogHint(logPath: string): string {
|
||||
const suffix = ` See ${logPath} for details.`;
|
||||
let handle: number | undefined;
|
||||
try {
|
||||
const { size } = statSync(logPath);
|
||||
if (size === 0) {
|
||||
return suffix;
|
||||
}
|
||||
const length = Math.min(size, CHILD_LOG_TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(length);
|
||||
handle = openSync(logPath, "r");
|
||||
const read = readSync(handle, buffer, 0, length, size - length);
|
||||
const lines = buffer
|
||||
.subarray(0, read)
|
||||
.toString("utf8")
|
||||
// A partial first line is likely when starting mid-file.
|
||||
.split("\n")
|
||||
.slice(size > length ? 1 : 0)
|
||||
.map((line) => stripAnsiCodes(line).trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-CHILD_LOG_TAIL_LINES);
|
||||
if (lines.length === 0) {
|
||||
return suffix;
|
||||
}
|
||||
return ` Last output from the child:\n${lines
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n")}\n${suffix.trimStart()}`;
|
||||
} catch {
|
||||
// The log is best-effort: a missing or unreadable file must never turn a
|
||||
// startup failure into a crash.
|
||||
return suffix;
|
||||
} finally {
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
closeSync(handle);
|
||||
} catch {
|
||||
// Nothing actionable if the descriptor is already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ConnectorBase<Options, State>
|
||||
implements ConnectCommandDefinition
|
||||
@@ -87,6 +156,31 @@ export abstract class ConnectorBase<Options, State>
|
||||
return this.runWithOptions(options, rawArgs, io, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance id `rawArgs` would run as, when that is knowable from the
|
||||
* arguments alone.
|
||||
*
|
||||
* The hub keys supervision by (channel, instanceId), so it needs the id
|
||||
* before anything is spawned. Adapters that can only determine it with a side
|
||||
* effect — Telegram resolves its bot username from the API when the flag is
|
||||
* omitted — return undefined, and the caller falls back to starting the
|
||||
* connector locally.
|
||||
*/
|
||||
resolveInstanceId(rawArgs: string[]): string | undefined {
|
||||
let options: Options;
|
||||
try {
|
||||
options = this.parseArgs(rawArgs);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const instanceId = this.instanceIdFromOptions(options);
|
||||
return instanceId?.trim() ? instanceId.trim() : undefined;
|
||||
}
|
||||
|
||||
protected instanceIdFromOptions(_options: Options): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
|
||||
let options: Options;
|
||||
try {
|
||||
@@ -170,6 +264,49 @@ export abstract class ConnectorBase<Options, State>
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusively claim the connector state path for this process before
|
||||
* connecting to Slack/Discord/etc. Prevents two foreground (`-i`) or
|
||||
* racing detached launches from both opening socket-mode with the same
|
||||
* bot token.
|
||||
*/
|
||||
protected claimConnectorInstance(input: {
|
||||
statePath: string;
|
||||
createState: (claimId: string) => State & { claimId: string; pid: number };
|
||||
readState: (path: string) => State | undefined;
|
||||
getPid: (state: State) => number;
|
||||
}): { claimed: true; claimId: string } | { claimed: false; running?: State } {
|
||||
const existing = input.readState(input.statePath);
|
||||
if (existing && isProcessRunning(input.getPid(existing))) {
|
||||
return { claimed: false, running: existing };
|
||||
}
|
||||
const claim = tryClaimConnectorStateFile(
|
||||
input.statePath,
|
||||
input.createState,
|
||||
);
|
||||
if (!claim) {
|
||||
const raced = input.readState(input.statePath);
|
||||
return {
|
||||
claimed: false,
|
||||
...(raced ? { running: raced } : {}),
|
||||
};
|
||||
}
|
||||
return { claimed: true, claimId: claim.claimId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a detached child's stdout/stderr is captured. Without this the
|
||||
* child is spawned with stdio "ignore", so a child that dies during startup
|
||||
* takes its only diagnostic with it and the parent can report nothing but
|
||||
* "child exited before becoming ready".
|
||||
*/
|
||||
protected resolveDetachedLogPath(statePath: string): string {
|
||||
return resolveConnectorDebugLogPath(
|
||||
this.name,
|
||||
basename(statePath, ".json") || this.name,
|
||||
);
|
||||
}
|
||||
|
||||
protected async maybeRunInBackground(input: {
|
||||
rawArgs: string[];
|
||||
io: ConnectIo;
|
||||
@@ -184,7 +321,13 @@ export abstract class ConnectorBase<Options, State>
|
||||
launchFailureMessage: string;
|
||||
startupTimeoutMs?: number;
|
||||
}): Promise<number | undefined> {
|
||||
if (input.interactive || process.env[input.childEnvVar] === "1") {
|
||||
if (
|
||||
input.interactive ||
|
||||
process.env[input.childEnvVar] === "1" ||
|
||||
// A supervised connector is the process the hub is tracking, so it must
|
||||
// run the adapter here instead of handing off to a detached child.
|
||||
isSupervisedConnectorProcess()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const runningState = input.readState(input.statePath);
|
||||
@@ -192,10 +335,16 @@ export abstract class ConnectorBase<Options, State>
|
||||
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
const logPath = this.resolveDetachedLogPath(input.statePath);
|
||||
const pid = spawnDetachedConnector(
|
||||
["connect", this.name],
|
||||
input.rawArgs,
|
||||
input.childEnvVar,
|
||||
{
|
||||
logPath,
|
||||
component: `${this.name}-connect`,
|
||||
metadata: { statePath: input.statePath },
|
||||
},
|
||||
);
|
||||
if (!pid) {
|
||||
input.io.writeErr(input.launchFailureMessage);
|
||||
@@ -212,7 +361,7 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
if (!isProcessRunning(pid)) {
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: child exited before becoming ready`,
|
||||
`${input.launchFailureMessage}: child exited before becoming ready.${formatChildLogHint(logPath)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -222,7 +371,7 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
await terminateProcess(pid);
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
|
||||
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms.${formatChildLogHint(logPath)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -189,3 +191,344 @@ describe("readSessionReplyText", () => {
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryClaimConnectorStateFile", () => {
|
||||
it("claims an empty path and rejects a second live claim", async () => {
|
||||
const { mkdtempSync, readFileSync, rmSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { tryClaimConnectorStateFile } = await import("./common");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
try {
|
||||
const first = tryClaimConnectorStateFile(
|
||||
statePath,
|
||||
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
|
||||
{
|
||||
isRunning: () => true,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
);
|
||||
expect(first).toBeDefined();
|
||||
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as {
|
||||
claimId: string;
|
||||
pid: number;
|
||||
};
|
||||
expect(parsed.claimId).toBe(first?.claimId);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
const second = tryClaimConnectorStateFile(
|
||||
statePath,
|
||||
(claimId) => ({
|
||||
claimId,
|
||||
pid: process.pid + 1,
|
||||
userName: "bot",
|
||||
}),
|
||||
{
|
||||
isRunning: () => true,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
);
|
||||
expect(second).toBeUndefined();
|
||||
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a dead-pid claim", async () => {
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { tryClaimConnectorStateFile } = await import("./common");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ pid: 1, userName: "stale" }),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
const claimed = tryClaimConnectorStateFile(
|
||||
statePath,
|
||||
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
|
||||
{
|
||||
isRunning: (pid) => pid === process.pid,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
);
|
||||
expect(claimed).toBeDefined();
|
||||
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows only one contender to replace the same stale generation", async () => {
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const firstPayload = `${JSON.stringify({
|
||||
claimId: "first",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const secondPayload = `${JSON.stringify({
|
||||
claimId: "second",
|
||||
pid: 3,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
firstPayload,
|
||||
{
|
||||
isRunning: () => false,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
secondPayload,
|
||||
{
|
||||
isRunning: () => false,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(firstPayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers when a stale-generation guard owner exits before replacement", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const replacementPayload = `${JSON.stringify({
|
||||
claimId: "replacement",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const generation = createHash("sha256").update(stalePayload).digest("hex");
|
||||
const orphanedGuardPath = `${statePath}.${generation}.claim`;
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
writeFileSync(
|
||||
orphanedGuardPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
claimId: "orphaned",
|
||||
pid: 3,
|
||||
processStartToken: "process-3",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
replacementPayload,
|
||||
{
|
||||
isRunning: () => false,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not succeed a live stale-generation guard owner", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const replacementPayload = `${JSON.stringify({
|
||||
claimId: "replacement",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const generation = createHash("sha256").update(stalePayload).digest("hex");
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
writeFileSync(
|
||||
`${statePath}.${generation}.claim`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
claimId: "live",
|
||||
pid: 3,
|
||||
processStartToken: "process-3",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
replacementPayload,
|
||||
{
|
||||
isRunning: (pid) => pid === 3,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(stalePayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers when an orphaned guard pid belongs to a different process", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const replacementPayload = `${JSON.stringify({
|
||||
claimId: "replacement",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const generation = createHash("sha256").update(stalePayload).digest("hex");
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
writeFileSync(
|
||||
`${statePath}.${generation}.claim`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
claimId: "orphaned",
|
||||
pid: 3,
|
||||
processStartToken: "original-process-3",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
replacementPayload,
|
||||
{
|
||||
isRunning: (pid) => pid === 3,
|
||||
getStartToken: (pid) =>
|
||||
pid === 3 ? "reused-process-3" : `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("detached connector log rotation", () => {
|
||||
it("keeps one generation once the log grows past the cap", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
|
||||
const logPath = join(dir, "cline-slack.log");
|
||||
writeFileSync(logPath, "x".repeat(__test__.DETACHED_LOG_MAX_BYTES + 1));
|
||||
|
||||
__test__.rotateOversizedLog(logPath);
|
||||
|
||||
expect(existsSync(logPath)).toBe(false);
|
||||
expect(readFileSync(`${logPath}.1`, "utf8").length).toBe(
|
||||
__test__.DETACHED_LOG_MAX_BYTES + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a small log in place so restarts keep their history", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
|
||||
const logPath = join(dir, "cline-slack.log");
|
||||
writeFileSync(logPath, "recent failure");
|
||||
|
||||
__test__.rotateOversizedLog(logPath);
|
||||
|
||||
expect(readFileSync(logPath, "utf8")).toBe("recent failure");
|
||||
expect(existsSync(`${logPath}.1`)).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when there is no log yet", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
|
||||
expect(() =>
|
||||
__test__.rotateOversizedLog(join(dir, "missing.log")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -28,6 +32,9 @@ export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
|
||||
*/
|
||||
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
|
||||
|
||||
/** Rotate a detached connector log once it passes this size. */
|
||||
const DETACHED_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -73,6 +80,70 @@ export function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessProbe = {
|
||||
isRunning: (pid: number) => boolean;
|
||||
getStartToken: (pid: number) => string | undefined;
|
||||
};
|
||||
|
||||
function getProcessStartToken(pid: number): string | undefined {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
if (commandEnd < 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Fields after the command name begin at field 3 (state), so field
|
||||
// 22 (starttime) is index 19.
|
||||
const startTime = stat
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/)[19];
|
||||
const bootId = readFileSync(
|
||||
"/proc/sys/kernel/random/boot_id",
|
||||
"utf8",
|
||||
).trim();
|
||||
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
|
||||
}
|
||||
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? spawnSync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
const startTime = result.status === 0 ? result.stdout.trim() : "";
|
||||
return startTime ? `${process.platform}:${startTime}` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProcessProbe: ProcessProbe = {
|
||||
isRunning: isProcessRunning,
|
||||
getStartToken: getProcessStartToken,
|
||||
};
|
||||
|
||||
export async function terminateProcess(pid: number): Promise<boolean> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return false;
|
||||
@@ -164,12 +235,30 @@ export function resolveConnectorDebugLogPath(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectors are long-lived and restart often, so an append-only log would grow
|
||||
* without bound on a host that runs them for weeks. Keep one previous
|
||||
* generation and start fresh once the current one gets large.
|
||||
*/
|
||||
function rotateOversizedLog(path: string): void {
|
||||
try {
|
||||
if (statSync(path).size < DETACHED_LOG_MAX_BYTES) {
|
||||
return;
|
||||
}
|
||||
rmSync(`${path}.1`, { force: true });
|
||||
renameSync(path, `${path}.1`);
|
||||
} catch {
|
||||
// No log yet, or it cannot be rotated: appending is still fine.
|
||||
}
|
||||
}
|
||||
|
||||
function tryOpenDetachedLogFd(path: string | undefined): number | undefined {
|
||||
if (!path?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
ensureParentDir(path);
|
||||
rotateOversizedLog(path);
|
||||
return openSync(path, "a");
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -269,6 +358,9 @@ export const __test__ = {
|
||||
buildDetachedConnectorArgs,
|
||||
buildDetachedConnectorCommand,
|
||||
buildDetachedConnectorEnv,
|
||||
tryReplaceStaleConnectorStateFile,
|
||||
rotateOversizedLog,
|
||||
DETACHED_LOG_MAX_BYTES,
|
||||
};
|
||||
|
||||
export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
@@ -289,6 +381,203 @@ export function writeJsonFile(path: string, value: unknown): void {
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a connector state path for this process.
|
||||
*
|
||||
* Uses O_EXCL so two concurrent `cline connect` launches cannot both observe
|
||||
* "no running instance" and both proceed. Returns undefined when another live
|
||||
* connector already owns the path (or a concurrent claim won the race).
|
||||
*/
|
||||
export function tryClaimConnectorStateFile(
|
||||
statePath: string,
|
||||
createState: (
|
||||
claimId: string,
|
||||
) => { claimId: string; pid: number } & Record<string, unknown>,
|
||||
processProbe: ProcessProbe = defaultProcessProbe,
|
||||
): { claimId: string } | undefined {
|
||||
ensureParentDir(statePath);
|
||||
const claimId = randomUUID();
|
||||
const state = createState(claimId);
|
||||
const payload = `${JSON.stringify(state, null, 2)}
|
||||
`;
|
||||
|
||||
if (tryCreateConnectorStateFile(statePath, payload)) {
|
||||
return { claimId };
|
||||
}
|
||||
|
||||
let observedPayload: string;
|
||||
try {
|
||||
observedPayload = readFileSync(statePath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const existing = JSON.parse(observedPayload) as { pid?: unknown };
|
||||
const existingPid =
|
||||
typeof existing.pid === "number" ? existing.pid : undefined;
|
||||
if (existingPid !== undefined && processProbe.isRunning(existingPid)) {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
observedPayload,
|
||||
payload,
|
||||
processProbe,
|
||||
)
|
||||
? { claimId }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function tryCreateConnectorStateFile(
|
||||
statePath: string,
|
||||
payload: string,
|
||||
): boolean {
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(statePath, "wx");
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined;
|
||||
if (code === "EEXIST") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
writeFileSync(fd, payload, "utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces exactly the stale generation that the caller observed.
|
||||
*
|
||||
* Each contender atomically links its ownership record into a guard keyed by
|
||||
* the observed generation. A live guard owner blocks replacement. If an owner
|
||||
* dies in the critical section, contenders append a successor guard rather
|
||||
* than deleting the existing one, so stale recovery remains crash-safe.
|
||||
*/
|
||||
function tryReplaceStaleConnectorStateFile(
|
||||
statePath: string,
|
||||
observedPayload: string,
|
||||
replacementPayload: string,
|
||||
processProbe: ProcessProbe = defaultProcessProbe,
|
||||
): boolean {
|
||||
let replacement: { claimId?: unknown; pid?: unknown };
|
||||
try {
|
||||
replacement = JSON.parse(replacementPayload) as {
|
||||
claimId?: unknown;
|
||||
pid?: unknown;
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof replacement.claimId !== "string" ||
|
||||
typeof replacement.pid !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const generation = createHash("sha256").update(observedPayload).digest("hex");
|
||||
const ownerPayload = `${JSON.stringify(
|
||||
{
|
||||
claimId: replacement.claimId,
|
||||
pid: replacement.pid,
|
||||
processStartToken: processProbe.getStartToken(replacement.pid),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`;
|
||||
const candidatePath = `${statePath}.${replacement.claimId}.candidate`;
|
||||
if (!tryCreateConnectorStateFile(candidatePath, ownerPayload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const guardPaths: string[] = [];
|
||||
let acquiredGuard = false;
|
||||
try {
|
||||
let guardPath = `${statePath}.${generation}.claim`;
|
||||
while (true) {
|
||||
guardPaths.push(guardPath);
|
||||
try {
|
||||
linkSync(candidatePath, guardPath);
|
||||
acquiredGuard = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined;
|
||||
if (code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let guardPayload: string;
|
||||
try {
|
||||
guardPayload = readFileSync(guardPath, "utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const guardOwner = JSON.parse(guardPayload) as {
|
||||
pid?: unknown;
|
||||
processStartToken?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof guardOwner.pid === "number" &&
|
||||
processProbe.isRunning(guardOwner.pid)
|
||||
) {
|
||||
const runningStartToken = processProbe.getStartToken(guardOwner.pid);
|
||||
if (
|
||||
typeof guardOwner.processStartToken !== "string" ||
|
||||
runningStartToken === undefined ||
|
||||
runningStartToken === guardOwner.processStartToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid ownership metadata cannot identify a live owner.
|
||||
}
|
||||
|
||||
const successor = createHash("sha256")
|
||||
.update(guardPath)
|
||||
.update("\0")
|
||||
.update(guardPayload)
|
||||
.digest("hex");
|
||||
guardPath = `${statePath}.${generation}.${successor}.claim`;
|
||||
}
|
||||
|
||||
if (readFileSync(statePath, "utf8") !== observedPayload) {
|
||||
return false;
|
||||
}
|
||||
rmSync(statePath);
|
||||
return tryCreateConnectorStateFile(statePath, replacementPayload);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
rmSync(candidatePath, { force: true });
|
||||
if (acquiredGuard) {
|
||||
for (const guardPath of guardPaths) {
|
||||
rmSync(guardPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
|
||||
@@ -178,6 +178,52 @@ describe("handleConnectorUserTurn", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("posts no greeting when the adapter configures none", async () => {
|
||||
// Slack deliberately configures no first-contact message: the greeting is
|
||||
// gated on per-thread state, so a restart or a cleared history replayed it
|
||||
// on the user's next message.
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
participantKey: "slack:user:alice",
|
||||
participantLabel: "alice",
|
||||
});
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
client: {} as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "cline-slack",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
text: "/whereami",
|
||||
});
|
||||
|
||||
expect(
|
||||
posts.filter((message) => messageText(message).includes("Connected")),
|
||||
).toEqual([]);
|
||||
// The turn itself still answers.
|
||||
expect(messageText(posts.at(-1))).toContain(
|
||||
"participantKey=slack:user:alice",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a first-contact message only once per persisted thread state", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -644,6 +690,81 @@ describe("handleConnectorUserTurn", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers when the bound session is wedged on a run that never drained", async () => {
|
||||
// Cline Mom's failure: the thread pointed at a session whose runtime still
|
||||
// had a run in flight, so every message came back as "SessionRuntime.shutdown
|
||||
// called while a run is in progress" instead of answering.
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "wedged-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("recovered reply");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "wedged-session") {
|
||||
// Crossing the hub's JSON boundary strips the error class, so only the
|
||||
// message survives — which is exactly what the connector sees.
|
||||
throw new Error(
|
||||
"SessionRuntime.shutdown called while a run is in progress (agentId=agent_123)",
|
||||
);
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
text: "recovered reply",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "are you there?",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
});
|
||||
|
||||
expect(
|
||||
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
|
||||
).toEqual(["wedged-session", "fresh-session"]);
|
||||
// The stale mapping is replaced, so the thread is not wedged next time.
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("run is in progress"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry forever when the replacement session is also missing", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -1777,7 +1898,11 @@ describe("handleConnectorUserTurn", () => {
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
|
||||
// Handing the follow-up to the running session is silent: no acknowledgement
|
||||
// line is added to the thread.
|
||||
expect(
|
||||
posts.some((message) => messageText(message).includes("Steering")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("steers when the same session is active under a different turn key", async () => {
|
||||
@@ -1829,7 +1954,11 @@ describe("handleConnectorUserTurn", () => {
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
|
||||
// Handing the follow-up to the running session is silent: no acknowledgement
|
||||
// line is added to the thread.
|
||||
expect(
|
||||
posts.some((message) => messageText(message).includes("Steering")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a normal turn when the active session is in a different thread", async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
HubSessionClient,
|
||||
UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { isSessionNotFoundError } from "@cline/core";
|
||||
import { isUnusableSessionError } from "@cline/core";
|
||||
import type { SentMessage, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
|
||||
@@ -975,10 +975,12 @@ export async function handleConnectorUserTurn<
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) {
|
||||
if (!isUnusableSessionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// The tracked turn points at a session the hub no longer knows about.
|
||||
// The tracked turn points at a session that can no longer serve it —
|
||||
// the hub does not know it, or its runtime is stuck on a run that never
|
||||
// drained.
|
||||
// Remove only the entry we attempted to steer, then route recovery
|
||||
// through the normal per-thread queue. Concurrent messages that saw
|
||||
// the same stale turn will line up behind this one instead of creating
|
||||
@@ -1002,11 +1004,10 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
return;
|
||||
}
|
||||
await postConnectorText(
|
||||
input.thread,
|
||||
input.transport,
|
||||
"Steering current task.",
|
||||
);
|
||||
// No acknowledgement: the follow-up is handed to the running session and its
|
||||
// effect shows up in the answer. Announcing it added a line to every thread
|
||||
// and overstated what happens, since the prompt is queued for the session
|
||||
// rather than injected into the loop already running.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1104,7 +1105,7 @@ async function runConnectorRuntimeTurnWithRecovery<
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!allowStaleSessionRetry || !isSessionNotFoundError(error)) {
|
||||
if (!allowStaleSessionRetry || !isUnusableSessionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
allowStaleSessionRetry = false;
|
||||
|
||||
@@ -62,7 +62,10 @@ vi.mock("../commands/auth", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
import {
|
||||
buildConnectorStartRequest,
|
||||
isReusableConnectorSession,
|
||||
} from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
@@ -161,3 +164,33 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("isReusableConnectorSession", () => {
|
||||
it("rejects missing and terminal sessions", () => {
|
||||
expect(isReusableConnectorSession(undefined)).toBe(false);
|
||||
expect(isReusableConnectorSession({ sessionId: "" })).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "completed" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "failed" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "aborted" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "cancelled" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts live and status-omitted sessions", () => {
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "running" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "idle" }),
|
||||
).toBe(true);
|
||||
expect(isReusableConnectorSession({ sessionId: "s1" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,29 @@ export function buildThreadStartRequest<TState extends ConnectorThreadState>(
|
||||
};
|
||||
}
|
||||
|
||||
/** Terminal hub statuses are not reusable for a new connector turn. */
|
||||
const TERMINAL_HUB_SESSION_STATUSES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export function isReusableConnectorSession(
|
||||
session: { sessionId?: string; status?: string } | undefined | null,
|
||||
): boolean {
|
||||
if (!session?.sessionId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
const status = session.status?.trim().toLowerCase();
|
||||
if (!status) {
|
||||
// Older hubs omit status; treat presence as reusable and let send-time
|
||||
// session_not_found recovery handle true zombies.
|
||||
return true;
|
||||
}
|
||||
return !TERMINAL_HUB_SESSION_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export async function getOrCreateSessionId<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
@@ -162,7 +185,7 @@ export async function getOrCreateSessionId<
|
||||
const existing = threadState.sessionId?.trim();
|
||||
if (existing) {
|
||||
const existingSession = await input.client.getSession(existing);
|
||||
if (existingSession) {
|
||||
if (isReusableConnectorSession(existingSession)) {
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
@@ -204,12 +227,17 @@ export async function getOrCreateSessionId<
|
||||
input.errorLabel,
|
||||
);
|
||||
input.logger.core.log(
|
||||
"Connector thread session missing; starting a new session",
|
||||
existingSession
|
||||
? "Connector thread session is terminal; starting a new session"
|
||||
: "Connector thread session missing; starting a new session",
|
||||
{
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
...(existingSession?.status
|
||||
? { status: existingSession.status }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,26 @@ export function writeBindings<TState extends ConnectorThreadState>(
|
||||
writeJsonFile(path, bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key under which turns for `thread` must be serialised.
|
||||
*
|
||||
* This has to follow the same identity rule as {@link findBindingForThread},
|
||||
* because whatever shares a session has to share a queue. A DM reuses one
|
||||
* binding — and therefore one runtime session — for every message in the
|
||||
* channel, so keying the queue by thread id would let two messages in the same
|
||||
* DM run against that one session concurrently. That surfaces as
|
||||
* "SessionRuntime.shutdown called while a run is in progress", or as two
|
||||
* conversations interleaved in one session's history.
|
||||
*
|
||||
* Channel threads each own their binding, so they keep their own key and go on
|
||||
* running independently of one another.
|
||||
*/
|
||||
export function resolveThreadTurnQueueKey(
|
||||
thread: Pick<ConnectorBindingThreadIdentity, "id" | "channelId" | "isDM">,
|
||||
): string {
|
||||
return thread.isDM ? `dm:${thread.channelId}` : thread.id;
|
||||
}
|
||||
|
||||
export function findBindingForThread<TState extends ConnectorThreadState>(
|
||||
bindings: ConnectorBindingStore<TState>,
|
||||
thread: ConnectorBindingThreadIdentity,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enqueueThreadTurn } from "./chat-runtime";
|
||||
import { resolveThreadTurnQueueKey } from "./thread-bindings";
|
||||
|
||||
describe("resolveThreadTurnQueueKey", () => {
|
||||
it("gives every channel thread its own key", () => {
|
||||
// Channel threads each own a binding and a session, so they run in parallel.
|
||||
const first = resolveThreadTurnQueueKey({
|
||||
id: "slack:C1:1111.1",
|
||||
channelId: "slack:C1",
|
||||
isDM: false,
|
||||
});
|
||||
const second = resolveThreadTurnQueueKey({
|
||||
id: "slack:C1:2222.2",
|
||||
channelId: "slack:C1",
|
||||
isDM: false,
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).toBe("slack:C1:1111.1");
|
||||
});
|
||||
|
||||
it("collapses every message in one DM onto a single key", () => {
|
||||
// findBindingForThread reuses one binding for a whole DM channel, so those
|
||||
// messages share a session and must not run concurrently.
|
||||
const first = resolveThreadTurnQueueKey({
|
||||
id: "slack:D1:1111.1",
|
||||
channelId: "slack:D1",
|
||||
isDM: true,
|
||||
});
|
||||
const second = resolveThreadTurnQueueKey({
|
||||
id: "slack:D1:2222.2",
|
||||
channelId: "slack:D1",
|
||||
isDM: true,
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("keeps separate DM channels separate", () => {
|
||||
expect(
|
||||
resolveThreadTurnQueueKey({
|
||||
id: "slack:D1:1111.1",
|
||||
channelId: "slack:D1",
|
||||
isDM: true,
|
||||
}),
|
||||
).not.toBe(
|
||||
resolveThreadTurnQueueKey({
|
||||
id: "slack:D2:1111.1",
|
||||
channelId: "slack:D2",
|
||||
isDM: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("thread turn scheduling", () => {
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it("runs two messages in the same DM one after the other", async () => {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
const dm = { id: "slack:D1:1.1", channelId: "slack:D1", isDM: true };
|
||||
const later = { id: "slack:D1:2.2", channelId: "slack:D1", isDM: true };
|
||||
const order: string[] = [];
|
||||
const first = deferred();
|
||||
|
||||
const firstTurn = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(dm),
|
||||
async () => {
|
||||
order.push("first:start");
|
||||
await first.promise;
|
||||
order.push("first:end");
|
||||
},
|
||||
);
|
||||
const secondTurn = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(later),
|
||||
async () => {
|
||||
order.push("second:start");
|
||||
},
|
||||
);
|
||||
|
||||
// The second message must not touch the shared session until the first
|
||||
// message's run has finished.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(order).toEqual(["first:start"]);
|
||||
|
||||
first.resolve();
|
||||
await Promise.all([firstTurn, secondTurn]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second:start"]);
|
||||
});
|
||||
|
||||
it("runs two channel threads at the same time", async () => {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
const threadA = { id: "slack:C1:1.1", channelId: "slack:C1", isDM: false };
|
||||
const threadB = { id: "slack:C1:2.2", channelId: "slack:C1", isDM: false };
|
||||
const order: string[] = [];
|
||||
const blocked = deferred();
|
||||
|
||||
const turnA = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(threadA),
|
||||
async () => {
|
||||
order.push("a:start");
|
||||
await blocked.promise;
|
||||
order.push("a:end");
|
||||
},
|
||||
);
|
||||
const turnB = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(threadB),
|
||||
async () => {
|
||||
order.push("b:start");
|
||||
},
|
||||
);
|
||||
|
||||
// B answers while A is still working: separate threads, separate sessions.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(order).toEqual(["a:start", "b:start"]);
|
||||
|
||||
blocked.resolve();
|
||||
await Promise.all([turnA, turnB]);
|
||||
expect(order).toEqual(["a:start", "b:start", "a:end"]);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,12 @@ export interface ConnectCommandDefinition {
|
||||
): Promise<number>;
|
||||
validate(args: string[], io: ConnectIo): Promise<number>;
|
||||
showHelp(io: ConnectIo): void;
|
||||
/**
|
||||
* Instance id `args` would run as, when it is knowable without side effects.
|
||||
* The hub keys connector supervision by (channel, instanceId) and needs it
|
||||
* before spawning; undefined sends the caller to the local start path.
|
||||
*/
|
||||
resolveInstanceId?(args: string[]): string | undefined;
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
}
|
||||
|
||||
+10
-2
@@ -2,9 +2,10 @@
|
||||
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import {
|
||||
claimHubDaemonProcess,
|
||||
claimSupervisedConnectorProcess,
|
||||
disposeAll,
|
||||
initVcr,
|
||||
isHubDaemonProcess,
|
||||
setConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { logCliProcessError } from "./logging/errors";
|
||||
@@ -22,11 +23,18 @@ initVcr(process.env.CLINE_VCR);
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (isHubDaemonProcess()) {
|
||||
} else if (claimHubDaemonProcess()) {
|
||||
// Claim rather than read: the sentinel is consumed here so the processes a
|
||||
// daemon-hosted session spawns do not inherit it and try to become daemons.
|
||||
// The hub daemon owns its process-level abort handling. Installing the CLI's
|
||||
// fatal rejection handler first would make expected abort rejections exit it.
|
||||
void import("@cline/core/hub/daemon-entry");
|
||||
} else {
|
||||
// Same reasoning as the daemon sentinel above: consume the supervised-connector
|
||||
// marker so the processes an agent session spawns cannot inherit it and mistake
|
||||
// themselves for the connector the hub is tracking.
|
||||
claimSupervisedConnectorProcess();
|
||||
|
||||
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
|
||||
if (cliLaunchSpec) {
|
||||
setConnectorCliLaunchSpec({
|
||||
|
||||
@@ -66,6 +66,7 @@ const dashboardMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const connectMocks = vi.hoisted(() => ({
|
||||
formatAdapterList: vi.fn(() => ""),
|
||||
runCleanupConnectorInstance: vi.fn(async () => 0),
|
||||
runConnectAdapter: vi.fn(async () => 0),
|
||||
runRestartConnector: vi.fn(async () => 0),
|
||||
runStopAllConnectors: vi.fn(async () => 0),
|
||||
@@ -396,6 +397,68 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a supervised cleanup to one connector instance", async () => {
|
||||
connectMocks.runCleanupConnectorInstance.mockClear();
|
||||
connectMocks.runConnectAdapter.mockClear();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--cleanup-instance",
|
||||
"cline-slack",
|
||||
"slack",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(connectMocks.runCleanupConnectorInstance).toHaveBeenCalledWith(
|
||||
"slack",
|
||||
"cline-slack",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects combining cleanup with another connect mode", async () => {
|
||||
connectMocks.runCleanupConnectorInstance.mockClear();
|
||||
connectMocks.runStopConnector.mockClear();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--cleanup-instance",
|
||||
"cline-slack",
|
||||
"--stop",
|
||||
"slack",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await runCli();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
|
||||
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a channel for a supervised cleanup", async () => {
|
||||
connectMocks.runCleanupConnectorInstance.mockClear();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--cleanup-instance",
|
||||
"x",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await runCli();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a targeted connector restart to one instance", async () => {
|
||||
process.argv = [
|
||||
"bun",
|
||||
|
||||
+47
-3
@@ -384,6 +384,10 @@ export async function runCli(): Promise<void> {
|
||||
"--restart-instance <id>",
|
||||
"Restart one connector instance (used by daemon recovery)",
|
||||
)
|
||||
.option(
|
||||
"--cleanup-instance <id>",
|
||||
"Reap one dead connector instance, preserving autostart (used by hub supervision)",
|
||||
)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.addHelpText(
|
||||
@@ -393,15 +397,34 @@ export async function runCli(): Promise<void> {
|
||||
.action(async (adapter: string | undefined) => {
|
||||
const {
|
||||
formatAdapterList,
|
||||
runCleanupConnectorInstance,
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
runStopConnector,
|
||||
} = await import("./commands/connect");
|
||||
const opts = connectCmd.opts();
|
||||
if (opts.stop && (opts.restart || opts.restartInstance)) {
|
||||
io.writeErr("connect accepts only one of --stop or --restart");
|
||||
const exclusiveModes = [
|
||||
opts.stop,
|
||||
opts.restart || opts.restartInstance,
|
||||
opts.cleanupInstance,
|
||||
].filter(Boolean).length;
|
||||
if (exclusiveModes > 1) {
|
||||
io.writeErr(
|
||||
"connect accepts only one of --stop, --restart or --cleanup-instance",
|
||||
);
|
||||
ctx.exitCode = 1;
|
||||
} else if (opts.cleanupInstance) {
|
||||
if (!adapter) {
|
||||
io.writeErr("connect --cleanup-instance requires a channel");
|
||||
ctx.exitCode = 1;
|
||||
} else {
|
||||
ctx.exitCode = await runCleanupConnectorInstance(
|
||||
adapter,
|
||||
opts.cleanupInstance,
|
||||
io,
|
||||
);
|
||||
}
|
||||
} else if (opts.stop) {
|
||||
if (adapter) {
|
||||
ctx.exitCode = await runStopConnector(adapter, io);
|
||||
@@ -482,6 +505,24 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const mcpUninstallCmd = mcpCmd
|
||||
.command("uninstall")
|
||||
.alias("remove")
|
||||
.alias("rm")
|
||||
.description("Uninstall an MCP server by name")
|
||||
.argument("<name>", "MCP server name")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (name: string) => {
|
||||
const opts = mcpUninstallCmd.opts<{
|
||||
json?: boolean;
|
||||
}>();
|
||||
const { runMcpUninstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpUninstallCommand({
|
||||
name,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
const createDoctorRuntimeCommand = async () => {
|
||||
const { createDoctorCommand } = await import("./commands/doctor");
|
||||
@@ -775,7 +816,10 @@ export async function runCli(): Promise<void> {
|
||||
// Enters the Agent Client Protocol stdio transport and never falls through.
|
||||
if (args.acpMode) {
|
||||
const { runAcpMode } = await import("./acp/index");
|
||||
await runAcpMode();
|
||||
// Only an explicit `--auto-approve true` (or `--yolo`) enables
|
||||
// auto-approval in ACP mode; We do not respect the default to
|
||||
// avoid accidental auto-approval in ACP mode.
|
||||
await runAcpMode({ autoApproveTools: args.autoApproveOverride === true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page-object helpers for the /settings view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
|
||||
import { expectVisible } from "../terminal.js";
|
||||
|
||||
const TAB_ORDER = [
|
||||
"API",
|
||||
"Auto-approve",
|
||||
"Features",
|
||||
"Account",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
export type SettingsTab = (typeof TAB_ORDER)[number];
|
||||
|
||||
/**
|
||||
* Navigate to a specific settings tab by pressing Right from the API tab (index 0).
|
||||
* Waits for each tab's content to appear before pressing the next key, making
|
||||
* navigation deterministic regardless of machine speed.
|
||||
*/
|
||||
export async function goToSettingsTab(
|
||||
terminal: Terminal,
|
||||
tab: SettingsTab,
|
||||
): Promise<void> {
|
||||
const targetIndex = TAB_ORDER.indexOf(tab);
|
||||
for (let i = 0; i < targetIndex; i++) {
|
||||
terminal.keyRight();
|
||||
// Wait for the next tab's content to appear before pressing again
|
||||
await assertTabContent(terminal, TAB_ORDER[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert the API tab content is visible */
|
||||
export async function assertApiTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Provider:", "Model ID:"]);
|
||||
}
|
||||
|
||||
/** Assert the Auto-approve tab content is visible */
|
||||
export async function assertAutoApproveTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, [
|
||||
"Read project files",
|
||||
"Execute safe commands",
|
||||
"Edit project files",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Assert the Features tab content is visible */
|
||||
export async function assertFeaturesTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, [
|
||||
"Subagents",
|
||||
"Web tools",
|
||||
"Double-check completion",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Assert the Account tab content is visible */
|
||||
export async function assertAccountTab(terminal: Terminal): Promise<void> {
|
||||
// The account tab shows sign-in options when not authenticated to Cline
|
||||
await expectVisible(terminal, /sign in|sign out/i);
|
||||
}
|
||||
|
||||
/** Assert the Other tab content is visible */
|
||||
export async function assertOtherTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Preferred language:", "Cline v"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the content for a given tab is visible.
|
||||
* Used internally by goToSettingsTab to confirm navigation landed correctly.
|
||||
*/
|
||||
export async function assertTabContent(
|
||||
terminal: Terminal,
|
||||
tab: SettingsTab,
|
||||
): Promise<void> {
|
||||
switch (tab) {
|
||||
case "API":
|
||||
return assertApiTab(terminal);
|
||||
case "Auto-approve":
|
||||
return assertAutoApproveTab(terminal);
|
||||
case "Features":
|
||||
return assertFeaturesTab(terminal);
|
||||
case "Account":
|
||||
return assertAccountTab(terminal);
|
||||
case "Other":
|
||||
return assertOtherTab(terminal);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type LocalSlashCommandName =
|
||||
| "plugins"
|
||||
| "account"
|
||||
| "model"
|
||||
| "theme"
|
||||
| "compact"
|
||||
| "skills"
|
||||
| "fork"
|
||||
@@ -62,6 +63,10 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
name: "model",
|
||||
description: "Switch model or provider",
|
||||
},
|
||||
{
|
||||
name: "theme",
|
||||
description: "Change color theme",
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
description: "View Cline account",
|
||||
@@ -112,6 +117,7 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
const SYSTEM_COMMAND_ORDER = [
|
||||
"settings",
|
||||
"model",
|
||||
"theme",
|
||||
"account",
|
||||
"mcp",
|
||||
"plugins",
|
||||
|
||||
@@ -3,8 +3,7 @@ import type {
|
||||
AutocompleteMode,
|
||||
AutocompleteOption,
|
||||
} from "../hooks/use-autocomplete";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
|
||||
const MAX_ROWS = 7;
|
||||
export const DROPDOWN_MAX_HEIGHT = MAX_ROWS + 2;
|
||||
@@ -19,7 +18,9 @@ export interface AutocompleteDropdownProps {
|
||||
}
|
||||
|
||||
export function AutocompleteDropdown(props: AutocompleteDropdownProps) {
|
||||
const { mode, options, selected, onSelect, accent = palette.act } = props;
|
||||
const theme = useTheme();
|
||||
const { mode, options, selected, onSelect } = props;
|
||||
const accent = props.accent ?? theme.accents.act;
|
||||
const { width: termWidth } = useTerminalDimensions();
|
||||
|
||||
if (!mode || options.length === 0) return null;
|
||||
@@ -122,8 +123,8 @@ function OptionRow(props: {
|
||||
accent: string;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
}) {
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const theme = useTheme();
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const { opt, isSelected, rowBudget, mode, accent, onSelect } = props;
|
||||
|
||||
if (opt.isHeader) {
|
||||
@@ -172,12 +173,12 @@ function OptionRow(props: {
|
||||
onMouseDown={() => onSelect(opt)}
|
||||
>
|
||||
<text wrapMode="none">
|
||||
<span fg={isSelected ? palette.textOnSelection : "gray"}>{prefix}</span>
|
||||
<span fg={isSelected ? palette.textOnSelection : defaultFg}>
|
||||
<span fg={isSelected ? theme.textOnSelection : "gray"}>{prefix}</span>
|
||||
<span fg={isSelected ? theme.textOnSelection : defaultFg}>
|
||||
{displayName}
|
||||
</span>
|
||||
{descText ? (
|
||||
<span fg={isSelected ? palette.textOnSelection : "gray"}>
|
||||
<span fg={isSelected ? theme.textOnSelection : "gray"}>
|
||||
{" ".repeat(descGap)}
|
||||
{descText}
|
||||
</span>
|
||||
|
||||
@@ -21,14 +21,8 @@ import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
} from "../cline-account";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getUserMessageBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import { getUserMessageBackground } from "../palette";
|
||||
import type { ResolvedTheme } from "../themes";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
@@ -218,7 +212,7 @@ function ToolCallView(props: {
|
||||
toolName: string;
|
||||
inputSummary: string;
|
||||
rawInput?: unknown;
|
||||
accent?: string;
|
||||
accent: string;
|
||||
defaultFg?: string;
|
||||
streaming: boolean;
|
||||
result?: {
|
||||
@@ -227,14 +221,8 @@ function ToolCallView(props: {
|
||||
error?: string;
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
toolName,
|
||||
inputSummary,
|
||||
streaming,
|
||||
result,
|
||||
accent = palette.act,
|
||||
defaultFg,
|
||||
} = props;
|
||||
const { toolName, inputSummary, streaming, result, accent, defaultFg } =
|
||||
props;
|
||||
const failed = result?.error != null;
|
||||
const warningFailure = isWarningToolError(result?.error);
|
||||
const params = formatToolParams(toolName, props.rawInput, inputSummary);
|
||||
@@ -279,7 +267,11 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
function ClineCreditsClinePassErrorView(props: {
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const linkColor = props.theme.accents.act;
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
@@ -301,7 +293,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={linkColor} selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
@@ -309,7 +301,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase ClinePass: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={linkColor} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -324,18 +316,26 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
|
||||
function ClineCreditsErrorView(props: {
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
return (
|
||||
<ClineCreditsClinePassErrorView
|
||||
defaultFg={props.defaultFg}
|
||||
theme={props.theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
const planAccent = props.theme.accents.plan;
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadIndividualSubscriptionPlans) {
|
||||
@@ -387,13 +387,13 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
)}
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={props.theme.accents.act} selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={props.theme.accents.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -404,9 +404,9 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
const planAccent = props.theme.accents.plan;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
@@ -464,21 +464,22 @@ function CompactionDividerRow(props: {
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
const accent = props.theme.accents.act;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<text fg={accent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={accent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.theme.accents.error}>ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
@@ -491,7 +492,7 @@ function ClinePassLimitErrorView(props: {
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
syntaxStyle={getSyntaxStyle(props.theme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
@@ -504,20 +505,24 @@ function ClinePassLimitErrorView(props: {
|
||||
function ClineFreeModelLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const resetTime = extractClineFreeModelLimitResetTime(props.message);
|
||||
const accent = props.theme.accents.act;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<text fg={accent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={accent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Daily free model limit reached</text>
|
||||
<text fg={props.theme.accents.error}>
|
||||
Daily free model limit reached
|
||||
</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -538,18 +543,22 @@ function ClineFreeModelLimitErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
|
||||
function ClineFreePromotionEndedErrorView(props: {
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const accent = props.theme.accents.act;
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<text fg={accent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={accent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Free model promotion ended</text>
|
||||
<text fg={props.theme.accents.error}>Free model promotion ended</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -572,12 +581,12 @@ export function ChatEntryView(props: {
|
||||
/** Mode the entry was produced in (resolved with the current-mode fallback). */
|
||||
mode?: SyntaxAccentMode;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const userMsgBg = getUserMessageBackground(terminalBg);
|
||||
const { entry, mode = "act", theme } = props;
|
||||
const accent = props.accent ?? theme.accents.act;
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const userMsgBg = getUserMessageBackground(theme.background);
|
||||
|
||||
switch (entry.kind) {
|
||||
case "user":
|
||||
@@ -633,7 +642,7 @@ export function ChatEntryView(props: {
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
@@ -660,13 +669,13 @@ export function ChatEntryView(props: {
|
||||
|
||||
case "error":
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} theme={theme} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -677,7 +686,7 @@ export function ChatEntryView(props: {
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -686,7 +695,7 @@ export function ChatEntryView(props: {
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -695,16 +704,26 @@ export function ChatEntryView(props: {
|
||||
<ClineFreeModelLimitErrorView
|
||||
defaultFg={defaultFg}
|
||||
message={entry.text}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
|
||||
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
|
||||
return (
|
||||
<ClineFreePromotionEndedErrorView
|
||||
defaultFg={defaultFg}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
<text fg="red" selectable content={`Error: ${entry.text}`} />
|
||||
<text fg={theme.accents.error} content="* " />
|
||||
<text
|
||||
fg={theme.accents.error}
|
||||
selectable
|
||||
content={`Error: ${entry.text}`}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { TranscriptCommand } from "../hooks/transcript-keybinds";
|
||||
import { useTerminalTheme } from "../hooks/use-terminal-background";
|
||||
import { getModeAccent } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { getThemeModeAccent } from "../themes";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { ChatEntryView } from "./chat-entry";
|
||||
|
||||
@@ -31,8 +31,8 @@ export const ChatMessageList = forwardRef<
|
||||
>(function ChatMessageList(props, ref) {
|
||||
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const lastEntry = props.entries.at(-1);
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const accent = getModeAccent(props.uiMode ?? "act", terminalTheme);
|
||||
const theme = useTheme();
|
||||
const accent = getThemeModeAccent(theme, props.uiMode ?? "act");
|
||||
const userSubmissionScrollKey =
|
||||
lastEntry?.kind === "user_submitted" ? props.entries.length : 0;
|
||||
|
||||
@@ -103,12 +103,12 @@ export const ChatMessageList = forwardRef<
|
||||
<ChatEntryView
|
||||
key={key}
|
||||
entry={entry}
|
||||
accent={getModeAccent(entryMode, terminalTheme)}
|
||||
accent={getThemeModeAccent(theme, entryMode)}
|
||||
mode={entryMode === "plan" ? "plan" : "act"}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
|
||||
| "settings"
|
||||
| "change-model"
|
||||
| "change-provider"
|
||||
| "theme"
|
||||
| "account"
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
|
||||
description: "Switch provider and configure credentials",
|
||||
keywords: ["provider", "api key", "account", "auth"],
|
||||
},
|
||||
{
|
||||
action: "theme",
|
||||
label: "Change Theme",
|
||||
shortcut: "Opt+T",
|
||||
description: "Pick a color theme for the TUI",
|
||||
keywords: ["theme", "colors", "dark", "light", "appearance"],
|
||||
},
|
||||
{
|
||||
action: "mcp",
|
||||
label: "Manage MCP Servers",
|
||||
|
||||
@@ -127,6 +127,12 @@ const HELP_ROWS: HelpRow[] = [
|
||||
key: "/settings",
|
||||
desc: "Open interactive config browser",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-theme",
|
||||
key: "/theme",
|
||||
desc: "Change color theme",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-mcp",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useThemeController } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { getThemeSwatchColors, THEMES } from "../../themes";
|
||||
|
||||
const SWATCH_BLOCK = "\u25a0";
|
||||
|
||||
export function ThemePickerContent(props: ChoiceContext<string>) {
|
||||
const { resolve, dismiss, dialogId } = props;
|
||||
const { height } = useTerminalDimensions();
|
||||
const controller = useThemeController();
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const index = THEMES.findIndex(
|
||||
(theme) => theme.id === controller.selectedThemeId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const controllerRef = useRef(controller);
|
||||
controllerRef.current = controller;
|
||||
|
||||
// Live preview: moving the selection repaints the whole TUI with the
|
||||
// highlighted theme so users see exactly what they would get.
|
||||
useEffect(() => {
|
||||
const theme = THEMES[selected];
|
||||
if (theme) {
|
||||
controllerRef.current.previewThemeId(theme.id);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
// Clear any dangling preview when the dialog closes without a confirm
|
||||
// (escape, backdrop click, dialog replaced). setThemeId already clears the
|
||||
// preview on confirm, so this is a no-op in that path.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current.previewThemeId(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter" || key.name === "tab") {
|
||||
const theme = THEMES[selectedRef.current];
|
||||
if (theme) {
|
||||
controllerRef.current.setThemeId(theme.id);
|
||||
resolve(theme.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === "up") {
|
||||
setSelected((index) => (index <= 0 ? THEMES.length - 1 : index - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === "down") {
|
||||
setSelected((index) => (index >= THEMES.length - 1 ? 0 : index + 1));
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
const maxVisible = Math.max(3, height - 10);
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
selected - Math.floor(maxVisible / 2),
|
||||
Math.max(0, THEMES.length - maxVisible),
|
||||
),
|
||||
);
|
||||
const visibleThemes = THEMES.slice(start, start + maxVisible);
|
||||
// Selection prefix (2 cells) + longest label + separating gap.
|
||||
const labelWidth = Math.max(...THEMES.map((theme) => theme.label.length)) + 4;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg="white">
|
||||
<strong>Theme</strong>
|
||||
</text>
|
||||
<text fg="gray">esc</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column">
|
||||
{visibleThemes.map((theme, i) => {
|
||||
const absoluteIndex = start + i;
|
||||
const isSelected = absoluteIndex === selected;
|
||||
const isCurrent = theme.id === controller.selectedThemeId;
|
||||
const swatches = getThemeSwatchColors(theme);
|
||||
return (
|
||||
<box
|
||||
key={theme.id}
|
||||
flexDirection="row"
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
onMouseDown={() => {
|
||||
setSelected(absoluteIndex);
|
||||
controllerRef.current.setThemeId(theme.id);
|
||||
resolve(theme.id);
|
||||
}}
|
||||
height={1}
|
||||
>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : "white"}
|
||||
width={labelWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSelected ? "\u276f " : " "}
|
||||
{theme.label}
|
||||
</text>
|
||||
<text flexShrink={0}>
|
||||
{swatches.map((color, swatchIndex) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-size color strip
|
||||
key={swatchIndex}
|
||||
fg={color}
|
||||
>
|
||||
{SWATCH_BLOCK}
|
||||
</span>
|
||||
))}
|
||||
</text>
|
||||
<text fg={isSelected ? palette.textOnSelection : "gray"}>
|
||||
{" "}
|
||||
{theme.description}
|
||||
{isCurrent ? " (current)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
<em>{"\u2191/\u2193 preview, Enter to apply, Esc to cancel"}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import type { RuntimeToolInteraction } from "../types";
|
||||
import { formatApprovalParams } from "./dialogs/tool-approval";
|
||||
|
||||
@@ -152,7 +152,7 @@ function Shell(
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={palette.act}>{props.title}</text>
|
||||
<text fg={props.accent}>{props.title}</text>
|
||||
</box>
|
||||
{props.children}
|
||||
</box>
|
||||
@@ -165,17 +165,18 @@ function ChoiceButton(props: {
|
||||
selectedFg?: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
paddingX={1}
|
||||
backgroundColor={props.selected ? palette.selection : undefined}
|
||||
backgroundColor={props.selected ? theme.selection : undefined}
|
||||
onMouseDown={props.onPress}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
props.selected
|
||||
? (props.selectedFg ?? palette.textOnSelection)
|
||||
? (props.selectedFg ?? theme.textOnSelection)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -190,6 +191,7 @@ function ToolApprovalResponse(
|
||||
interaction: Extract<RuntimeToolInteraction, { kind: "tool_approval" }>;
|
||||
},
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const [selected, setSelected] = useState<"approve" | "deny">("approve");
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
@@ -231,7 +233,7 @@ function ToolApprovalResponse(
|
||||
inputForeground={props.inputForeground}
|
||||
>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Approve tool call?</text>
|
||||
<text fg={theme.accents.plan}>Approve tool call?</text>
|
||||
<text fg={props.accent} selectable>
|
||||
{request.toolName}
|
||||
</text>
|
||||
@@ -263,6 +265,7 @@ function AskQuestionResponse(
|
||||
},
|
||||
) {
|
||||
const { interaction } = props;
|
||||
const theme = useTheme();
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
@@ -439,13 +442,11 @@ function AskQuestionResponse(
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={
|
||||
optionSelected ? palette.selection : undefined
|
||||
}
|
||||
backgroundColor={optionSelected ? theme.selection : undefined}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
fg={optionSelected ? theme.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
@@ -453,7 +454,7 @@ function AskQuestionResponse(
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
? theme.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
flexGrow={1}
|
||||
@@ -472,17 +473,17 @@ function AskQuestionResponse(
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
backgroundColor={isTyping ? theme.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text
|
||||
fg={isTyping ? palette.textOnSelection : "gray"}
|
||||
fg={isTyping ? theme.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
<text fg={theme.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
{customText}
|
||||
</text>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "opentui-spinner/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import { palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import type { QueuedPromptItem } from "../types";
|
||||
|
||||
function truncatePrompt(prompt: string): string {
|
||||
@@ -20,6 +20,7 @@ export function QueuedPrompts(props: {
|
||||
onEditConfirm: (id: string, prompt: string) => void;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const theme = useTheme();
|
||||
if (props.items.length === 0) return null;
|
||||
|
||||
const selected = props.selectedId
|
||||
@@ -42,7 +43,7 @@ export function QueuedPrompts(props: {
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={selected ? palette.selection : "gray"}
|
||||
borderColor={selected ? theme.selection : "gray"}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="gray">
|
||||
@@ -75,6 +76,7 @@ function QueuedPromptRow(props: {
|
||||
onEditConfirm: (prompt: string) => void;
|
||||
}) {
|
||||
const { item, selected, editing } = props;
|
||||
const theme = useTheme();
|
||||
const [editValue, setEditValue] = useState(item.prompt);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,15 +90,15 @@ function QueuedPromptRow(props: {
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={selected ? palette.selection : undefined}
|
||||
backgroundColor={selected ? theme.selection : undefined}
|
||||
>
|
||||
{item.steer && !editing ? (
|
||||
<spinner
|
||||
name="dots"
|
||||
color={selected ? palette.textOnSelection : "gray"}
|
||||
color={selected ? theme.textOnSelection : "gray"}
|
||||
/>
|
||||
) : (
|
||||
<text fg={selected ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
<text fg={selected ? theme.textOnSelection : "gray"} flexShrink={0}>
|
||||
{selected ? "❯" : " "}
|
||||
</text>
|
||||
)}
|
||||
@@ -106,21 +108,24 @@ function QueuedPromptRow(props: {
|
||||
onInput={setEditValue}
|
||||
onSubmit={() => props.onEditConfirm(editValue)}
|
||||
placeholder="Edit message..."
|
||||
backgroundColor={palette.selection}
|
||||
focusedBackgroundColor={palette.selection}
|
||||
textColor={palette.textOnSelection}
|
||||
cursorColor={palette.textOnSelection}
|
||||
placeholderColor={palette.textOnSelection}
|
||||
backgroundColor={theme.selection}
|
||||
focusedBackgroundColor={theme.selection}
|
||||
textColor={theme.textOnSelection}
|
||||
cursorColor={theme.textOnSelection}
|
||||
placeholderColor={theme.textOnSelection}
|
||||
focused
|
||||
flexGrow={1}
|
||||
/>
|
||||
) : (
|
||||
<text fg={selected ? palette.textOnSelection : undefined} flexGrow={1}>
|
||||
<text
|
||||
fg={selected ? theme.textOnSelection : theme.defaultForeground}
|
||||
flexGrow={1}
|
||||
>
|
||||
{truncatePrompt(item.prompt)}
|
||||
</text>
|
||||
)}
|
||||
{!editing && item.attachmentCount > 0 && (
|
||||
<text fg={selected ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
<text fg={selected ? theme.textOnSelection : "gray"} flexShrink={0}>
|
||||
{attachmentLabel(item.attachmentCount)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
|
||||
export interface SearchableItem {
|
||||
key: string;
|
||||
@@ -229,8 +228,8 @@ export function SearchableList(props: {
|
||||
emptyText?: string;
|
||||
borderColor?: string;
|
||||
}) {
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const theme = useTheme();
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const {
|
||||
items,
|
||||
selected,
|
||||
@@ -288,23 +287,23 @@ export function SearchableList(props: {
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
backgroundColor={isSel ? theme.selection : undefined}
|
||||
onMouseDown={() => onItemSelect?.(item)}
|
||||
overflow="hidden"
|
||||
height={1}
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
fg={isSel ? theme.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text fg={isSel ? palette.textOnSelection : defaultFg}>
|
||||
<text fg={isSel ? theme.textOnSelection : defaultFg}>
|
||||
{item.label}
|
||||
</text>
|
||||
{item.detail && (
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
fg={isSel ? theme.textOnSelection : "gray"}
|
||||
flexShrink={1}
|
||||
>
|
||||
{item.detail}
|
||||
@@ -313,9 +312,7 @@ export function SearchableList(props: {
|
||||
{item.tag && (
|
||||
<text
|
||||
fg={
|
||||
isSel
|
||||
? palette.textOnSelection
|
||||
: (item.tagColor ?? "gray")
|
||||
isSel ? theme.textOnSelection : (item.tagColor ?? "gray")
|
||||
}
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -326,8 +323,8 @@ export function SearchableList(props: {
|
||||
<text
|
||||
fg={
|
||||
isSel
|
||||
? palette.textOnSelection
|
||||
: (item.rightLabelColor ?? palette.success)
|
||||
? theme.textOnSelection
|
||||
: (item.rightLabelColor ?? theme.accents.success)
|
||||
}
|
||||
flexShrink={0}
|
||||
>
|
||||
|
||||
@@ -4,15 +4,7 @@ import {
|
||||
shouldShowCliUsageCost,
|
||||
shouldShowCliUsageCoveredBySubscription,
|
||||
} from "../../utils/usage-cost-display";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getSuccessColor,
|
||||
} from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { HOME_VIEW_MAX_WIDTH } from "../types";
|
||||
|
||||
export function createContextBar(
|
||||
@@ -163,13 +155,12 @@ export function StatusBar(props: StatusBarProps) {
|
||||
} = props;
|
||||
|
||||
const { width } = useTerminalDimensions();
|
||||
const terminalBg = useTerminalBackground();
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const theme = useTheme();
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const contextBarFilledFg = resolveContextBarFilledForeground(defaultFg);
|
||||
const actAccent = getModeAccent("act", terminalTheme);
|
||||
const planAccent = getModeAccent("plan", terminalTheme);
|
||||
const successColor = getSuccessColor(terminalTheme);
|
||||
const actAccent = theme.accents.act;
|
||||
const planAccent = theme.accents.plan;
|
||||
const successColor = theme.accents.success;
|
||||
const hasMaxInputTokens =
|
||||
typeof maxInputTokens === "number" &&
|
||||
Number.isFinite(maxInputTokens) &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import { palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
|
||||
export type ToastVariant = "info" | "success" | "error";
|
||||
|
||||
@@ -8,19 +8,19 @@ export type ToastState = {
|
||||
variant: ToastVariant;
|
||||
};
|
||||
|
||||
const variantColor: Record<ToastVariant, string> = {
|
||||
info: palette.selection,
|
||||
success: palette.success,
|
||||
error: palette.error,
|
||||
};
|
||||
|
||||
export function Toast(props: { toast: ToastState | null }) {
|
||||
const { width } = useTerminalDimensions();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!props.toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const variantColor: Record<ToastVariant, string> = {
|
||||
info: theme.accents.act,
|
||||
success: theme.accents.success,
|
||||
error: theme.accents.error,
|
||||
};
|
||||
const availableWidth = Math.max(1, width - 4);
|
||||
const maxWidth = Math.min(44, availableWidth);
|
||||
const right = width < 32 ? 0 : 2;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTerminalTheme } from "../hooks/use-terminal-background";
|
||||
import { diffPalettes, palette, type TerminalTheme } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import type { ResolvedTheme } from "../themes";
|
||||
import { makeUnifiedDiff } from "../utils/diff";
|
||||
import { getSyntaxStyle } from "../utils/syntax-style";
|
||||
import { getToolErrorPresentation } from "../utils/tool-errors";
|
||||
@@ -45,7 +45,7 @@ function isEditTool(toolName: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function BashOutput(props: { fullText: string; theme: TerminalTheme }) {
|
||||
function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { fullText } = props;
|
||||
const trimmed = fullText.trimEnd();
|
||||
@@ -115,18 +115,19 @@ function DiffStats(props: {
|
||||
added: number;
|
||||
removed: number;
|
||||
language?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const { added, removed, language } = props;
|
||||
const { added, removed, language, theme } = props;
|
||||
return (
|
||||
<text fg="gray">
|
||||
{RESULT}{" "}
|
||||
{removed > 0 ? (
|
||||
<>
|
||||
<span fg={palette.success}>+{added}</span>{" "}
|
||||
<span fg="red">-{removed}</span> lines
|
||||
<span fg={theme.accents.success}>+{added}</span>{" "}
|
||||
<span fg={theme.accents.error}>-{removed}</span> lines
|
||||
</>
|
||||
) : (
|
||||
<span fg={palette.success}>+{added} lines (new)</span>
|
||||
<span fg={theme.accents.success}>+{added} lines (new)</span>
|
||||
)}
|
||||
{language ? ` | ${language}` : ""}
|
||||
</text>
|
||||
@@ -136,7 +137,7 @@ function DiffStats(props: {
|
||||
function EditOutput(props: {
|
||||
rawInput?: unknown;
|
||||
outputSummary: string;
|
||||
theme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const editorInfo = parseEditorInput(props.rawInput);
|
||||
@@ -156,7 +157,7 @@ function EditOutput(props: {
|
||||
const language = detectLanguage(editorInfo.path);
|
||||
const addedLines = newText.split("\n").length;
|
||||
const removedLines = oldText ? oldText.split("\n").length : 0;
|
||||
const diffPalette = diffPalettes[props.theme];
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -168,6 +169,7 @@ function EditOutput(props: {
|
||||
added={addedLines}
|
||||
removed={removedLines}
|
||||
language={language}
|
||||
theme={props.theme}
|
||||
/>
|
||||
{expanded && (
|
||||
<box marginLeft={2} marginTop={1} marginBottom={1}>
|
||||
@@ -194,7 +196,7 @@ function EditOutput(props: {
|
||||
function ApplyPatchOutput(props: {
|
||||
rawInput?: unknown;
|
||||
outputSummary: string;
|
||||
theme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const info = parseApplyPatchInput(props.rawInput);
|
||||
@@ -211,7 +213,7 @@ function ApplyPatchOutput(props: {
|
||||
|
||||
const fileLabel = info.files.map((f) => shortenPath(f, 40)).join(", ");
|
||||
const language = detectLanguage(info.files[0] ?? "");
|
||||
const diffPalette = diffPalettes[props.theme];
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -223,6 +225,7 @@ function ApplyPatchOutput(props: {
|
||||
added={info.additions}
|
||||
removed={info.deletions}
|
||||
language={fileLabel}
|
||||
theme={props.theme}
|
||||
/>
|
||||
{expanded && (
|
||||
<box marginLeft={2} marginTop={1} marginBottom={1}>
|
||||
@@ -292,7 +295,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
|
||||
export function ToolOutput(props: ToolOutputProps) {
|
||||
const { toolName, outputSummary, rawOutput, rawInput, error } = props;
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const terminalTheme = useTheme();
|
||||
const [errorExpanded, setErrorExpanded] = useState(false);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import { getDefaultForeground } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { RobotAnimation } from "./robot-animation";
|
||||
|
||||
export function useMouseTracker() {
|
||||
@@ -18,8 +17,7 @@ export function useMouseTracker() {
|
||||
}
|
||||
|
||||
export function TrackedRobot(props: { cursorX?: number; cursorY?: number }) {
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const defaultFg = useTheme().defaultForeground;
|
||||
return (
|
||||
<box width="100%" flexShrink={1} overflow="hidden">
|
||||
<RobotAnimation
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LocalSlashCommandActionInput {
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
openModelSelector: () => void;
|
||||
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
|
||||
openThemePicker: () => void;
|
||||
invocation?: LocalSlashCommandInvocation;
|
||||
runCompact: () => void;
|
||||
runFork: () => void;
|
||||
@@ -46,6 +47,10 @@ export function runLocalSlashCommandAction(
|
||||
input.openModelSelector();
|
||||
return true;
|
||||
}
|
||||
if (normalized === "theme") {
|
||||
input.openThemePicker();
|
||||
return true;
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
// Autocomplete can invoke local commands while a turn is running. Keep
|
||||
// /compact handled, but do not let it take ownership of the active turn's
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readTuiThemeGlobally, setTuiThemeGlobally } from "@cline/core";
|
||||
import { useRenderer } from "@opentui/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AUTO_THEME_ID, normalizeThemeId, resolveTheme } from "../themes";
|
||||
import { TerminalColorsContext, ThemeContext } from "./use-theme";
|
||||
|
||||
/**
|
||||
* Resolves the theme to boot with: CLINE_THEME env override first, then the
|
||||
* persisted setting, falling back to terminal auto-detection.
|
||||
*/
|
||||
export function getInitialThemeId(): string {
|
||||
const fromEnv = process.env.CLINE_THEME?.trim();
|
||||
if (fromEnv) {
|
||||
return normalizeThemeId(fromEnv);
|
||||
}
|
||||
try {
|
||||
return normalizeThemeId(readTuiThemeGlobally());
|
||||
} catch {
|
||||
return AUTO_THEME_ID;
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider(props: {
|
||||
initialThemeId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const detected = useContext(TerminalColorsContext);
|
||||
const renderer = useRenderer();
|
||||
const [selectedThemeId, setSelectedThemeId] = useState(() =>
|
||||
normalizeThemeId(props.initialThemeId ?? getInitialThemeId()),
|
||||
);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
|
||||
const activeThemeId = previewId ?? selectedThemeId;
|
||||
const theme = useMemo(
|
||||
() => resolveTheme(activeThemeId, detected),
|
||||
[activeThemeId, detected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setBackgroundColor(theme.appBackground ?? "transparent");
|
||||
}, [renderer, theme.appBackground]);
|
||||
|
||||
const setThemeId = useCallback((id: string) => {
|
||||
const normalized = normalizeThemeId(id);
|
||||
setSelectedThemeId(normalized);
|
||||
setPreviewId(null);
|
||||
try {
|
||||
setTuiThemeGlobally(normalized);
|
||||
} catch {
|
||||
// Persisting is best-effort; the in-session theme still applies.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const previewThemeId = useCallback((id: string | null) => {
|
||||
setPreviewId(id === null ? null : normalizeThemeId(id));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ theme, selectedThemeId, setThemeId, previewThemeId }),
|
||||
[theme, selectedThemeId, setThemeId, previewThemeId],
|
||||
);
|
||||
|
||||
return <ThemeContext value={value}>{props.children}</ThemeContext>;
|
||||
}
|
||||
@@ -216,6 +216,20 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
finalizeDanglingCompactionEntry("cancelled");
|
||||
break;
|
||||
case "error":
|
||||
// Recoverable errors are in-run notices (the MistakeTracker
|
||||
// emits one for every recorded mistake, e.g. a plan-mode
|
||||
// guard-blocked command) — the run continues, so the footer
|
||||
// must keep reflecting the active turn instead of flipping
|
||||
// to idle mid-run. Surface them only in verbose mode.
|
||||
if (event.recoverable) {
|
||||
if (verbose) {
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error, { modelId }),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
@@ -223,12 +237,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
finalizeDanglingCompactionEntry("failed");
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error, { modelId }),
|
||||
});
|
||||
}
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error, { modelId }),
|
||||
});
|
||||
break;
|
||||
case "notice":
|
||||
if (event.displayRole === "status") {
|
||||
|
||||
@@ -45,6 +45,7 @@ export function useConfigPanel(opts: {
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
|
||||
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
|
||||
openThemePicker: (options?: { refocus?: boolean }) => Promise<void>;
|
||||
refocusTextarea: () => void;
|
||||
}) {
|
||||
const emptyConfigData = useMemo(
|
||||
@@ -118,6 +119,8 @@ export function useConfigPanel(opts: {
|
||||
});
|
||||
} else if (action.kind === "open-model") {
|
||||
await opts.openModelSelector({ onCancel: () => {} });
|
||||
} else if (action.kind === "open-theme") {
|
||||
await opts.openThemePicker({ refocus: false });
|
||||
} else if (action.kind === "toggle-item") {
|
||||
await opts.onToggleConfigItem?.(action.item);
|
||||
} else if (action.kind === "delete-item") {
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeActions(
|
||||
openMcpManager: vi.fn(async () => false),
|
||||
openModelSelector: vi.fn(),
|
||||
openSkills: vi.fn(),
|
||||
openThemePicker: vi.fn(),
|
||||
runCompact: vi.fn(),
|
||||
runFork: vi.fn(),
|
||||
runUndo: vi.fn(async () => {}),
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useLocalCommandActions(input: {
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
openModelSelector: () => void;
|
||||
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
|
||||
openThemePicker: () => void;
|
||||
refocusTextarea: () => void;
|
||||
setAppView: (view: AppView) => void;
|
||||
onClearConversation: () => Promise<void>;
|
||||
@@ -45,6 +46,7 @@ export function useLocalCommandActions(input: {
|
||||
openMcpManager,
|
||||
openModelSelector,
|
||||
openSkills,
|
||||
openThemePicker,
|
||||
refocusTextarea,
|
||||
setAppView,
|
||||
onClearConversation,
|
||||
@@ -227,6 +229,7 @@ export function useLocalCommandActions(input: {
|
||||
openMcpManager,
|
||||
openModelSelector,
|
||||
openSkills,
|
||||
openThemePicker,
|
||||
runCompact,
|
||||
runFork,
|
||||
runUndo: onUndo,
|
||||
@@ -247,6 +250,7 @@ export function useLocalCommandActions(input: {
|
||||
openHistory,
|
||||
openModelSelector,
|
||||
openSkills,
|
||||
openThemePicker,
|
||||
runCompact,
|
||||
runFork,
|
||||
session.isRunning,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { getTerminalTheme, type TerminalTheme } from "../palette";
|
||||
|
||||
export interface TerminalColors {
|
||||
background: string | null;
|
||||
foreground: string | null;
|
||||
}
|
||||
|
||||
export const TerminalColorsContext = createContext<TerminalColors>({
|
||||
background: null,
|
||||
foreground: null,
|
||||
});
|
||||
|
||||
export function useTerminalBackground(): string | null {
|
||||
return useContext(TerminalColorsContext).background;
|
||||
}
|
||||
|
||||
export function useTerminalForeground(): string | null {
|
||||
return useContext(TerminalColorsContext).foreground;
|
||||
}
|
||||
|
||||
export function useTerminalTheme(): TerminalTheme {
|
||||
const { background, foreground } = useContext(TerminalColorsContext);
|
||||
return getTerminalTheme(background, foreground);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
|
||||
|
||||
export interface TerminalColors {
|
||||
background: string | null;
|
||||
foreground: string | null;
|
||||
}
|
||||
|
||||
export const TerminalColorsContext = createContext<TerminalColors>({
|
||||
background: null,
|
||||
foreground: null,
|
||||
});
|
||||
|
||||
export interface ThemeController {
|
||||
theme: ResolvedTheme;
|
||||
/** The persisted selection (previews do not change this). */
|
||||
selectedThemeId: string;
|
||||
/** Select and persist a theme. */
|
||||
setThemeId: (id: string) => void;
|
||||
/** Temporarily render a theme (live preview); null reverts to selection. */
|
||||
previewThemeId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
/** Provided by ThemeProvider (see theme-provider.tsx). */
|
||||
export const ThemeContext = createContext<ThemeController | null>(null);
|
||||
|
||||
export function useThemeController(): ThemeController {
|
||||
const controller = useContext(ThemeContext);
|
||||
if (!controller) {
|
||||
throw new Error("useThemeController must be used within ThemeProvider");
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function useTheme(): ResolvedTheme {
|
||||
const controller = useContext(ThemeContext);
|
||||
const detected = useContext(TerminalColorsContext);
|
||||
// Fall back to auto resolution so components render sensibly when mounted
|
||||
// without a ThemeProvider (e.g. in isolated tests).
|
||||
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background that adaptive colors (input field, user bubbles, rules) derive
|
||||
* from: the theme's painted background when set, else the detected one.
|
||||
*/
|
||||
export function useTerminalBackground(): string | null {
|
||||
return useTheme().background;
|
||||
}
|
||||
|
||||
export function useTerminalForeground(): string | null {
|
||||
return useContext(TerminalColorsContext).foreground;
|
||||
}
|
||||
|
||||
export function useTerminalTheme(): TerminalTheme {
|
||||
return useTheme().variant;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createCliRenderer } from "@opentui/core";
|
||||
import { createRoot } from "@opentui/react";
|
||||
import { getInitialThemeId } from "./hooks/theme-provider";
|
||||
import { Root } from "./root";
|
||||
import { installTuiStdioCapture } from "./stdio-capture";
|
||||
import { resolveTheme } from "./themes";
|
||||
import type { TuiProps } from "./types";
|
||||
|
||||
export type { TuiProps } from "./types";
|
||||
@@ -22,6 +24,17 @@ export async function renderOpenTui(
|
||||
const terminalBackground = detectedPalette?.defaultBackground ?? null;
|
||||
const terminalForeground = detectedPalette?.defaultForeground ?? null;
|
||||
|
||||
// Paint the selected theme's background before the first frame so themed
|
||||
// sessions don't flash the terminal's own background on startup.
|
||||
const initialThemeId = getInitialThemeId();
|
||||
const initialTheme = resolveTheme(initialThemeId, {
|
||||
background: terminalBackground,
|
||||
foreground: terminalForeground,
|
||||
});
|
||||
if (initialTheme.appBackground) {
|
||||
renderer.setBackgroundColor(initialTheme.appBackground);
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
try {
|
||||
root = createRoot(renderer);
|
||||
@@ -30,6 +43,7 @@ export async function renderOpenTui(
|
||||
{...props}
|
||||
terminalBackground={terminalBackground}
|
||||
terminalForeground={terminalForeground}
|
||||
initialThemeId={initialThemeId}
|
||||
/>,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { getMcpDescription } from "./interactive-config";
|
||||
|
||||
describe("getMcpDescription", () => {
|
||||
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
|
||||
it("discloses the default initialize timeout for unconfigured stdio servers", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
|
||||
});
|
||||
|
||||
it("shows one configured timeout when it also applies to initialize", () => {
|
||||
@@ -40,6 +40,6 @@ describe("getMcpDescription", () => {
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: Number.NaN,
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
@@ -10,7 +9,9 @@ import {
|
||||
} from "node:path";
|
||||
import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginToolsWithDiagnostics,
|
||||
@@ -183,7 +184,7 @@ export function getMcpDescription(registration: McpServerRegistration): string {
|
||||
const timeoutDescription =
|
||||
registration.transport.type === "stdio" &&
|
||||
!isMcpTimeoutConfigured(registration.timeoutSeconds)
|
||||
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
|
||||
? `request timeout ${timeoutSeconds}s, initialize timeout ${DEFAULT_MCP_CONNECT_TIMEOUT_MS / 1000}s`
|
||||
: `timeout ${timeoutSeconds}s`;
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
|
||||
}
|
||||
@@ -243,40 +244,6 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
return [...agentsById.values()];
|
||||
}
|
||||
|
||||
function readPackageName(packageJsonPath: string): string | undefined {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: unknown;
|
||||
};
|
||||
return typeof packageJson.name === "string" && packageJson.name.trim()
|
||||
? packageJson.name.trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
if (packageName) {
|
||||
return packageName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return basename(filePath, extname(filePath));
|
||||
}
|
||||
|
||||
function isPathWithin(parentPath: string, childPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModeAccent, getSuccessColor, getTerminalTheme } from "./palette";
|
||||
import { getTerminalTheme, themePalette } from "./palette";
|
||||
|
||||
describe("getTerminalTheme", () => {
|
||||
it("detects light terminals from the default background", () => {
|
||||
@@ -24,14 +24,14 @@ describe("getTerminalTheme", () => {
|
||||
|
||||
describe("theme-aware palette helpers", () => {
|
||||
it("uses the brand accent colors for dark terminals", () => {
|
||||
expect(getModeAccent("act", "dark")).toBe("#79b8ff");
|
||||
expect(getModeAccent("plan", "dark")).toBe("#ffea7f");
|
||||
expect(getSuccessColor("dark")).toBe("#99e89b");
|
||||
expect(themePalette.dark.act).toBe("#79b8ff");
|
||||
expect(themePalette.dark.plan).toBe("#ffea7f");
|
||||
expect(themePalette.dark.success).toBe("#99e89b");
|
||||
});
|
||||
|
||||
it("uses darker accents on light terminals", () => {
|
||||
expect(getModeAccent("act", "light")).toBe("#0f72cb");
|
||||
expect(getModeAccent("plan", "light")).toBe("#867100");
|
||||
expect(getSuccessColor("light")).toBe("#116329");
|
||||
expect(themePalette.light.act).toBe("#0f72cb");
|
||||
expect(themePalette.light.plan).toBe("#867100");
|
||||
expect(themePalette.light.success).toBe("#116329");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,17 +46,6 @@ export const diffPalettes = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getModeAccent(
|
||||
mode: string,
|
||||
theme: TerminalTheme = "dark",
|
||||
): string {
|
||||
return mode === "plan" ? themePalette[theme].plan : themePalette[theme].act;
|
||||
}
|
||||
|
||||
export function getSuccessColor(theme: TerminalTheme = "dark"): string {
|
||||
return themePalette[theme].success;
|
||||
}
|
||||
|
||||
// Input field adaptive color system
|
||||
//
|
||||
// The input field background needs to be visibly distinct from the terminal
|
||||
@@ -208,6 +197,8 @@ export function getModeInputPlaceholder(
|
||||
);
|
||||
}
|
||||
|
||||
export { hexToOklab, oklabToHex };
|
||||
|
||||
function srgbToLinear(c: number): number {
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
+63
-18
@@ -11,8 +11,11 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
|
||||
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
import {
|
||||
isSameRepoStatus,
|
||||
type RepoStatus,
|
||||
readRepoStatus,
|
||||
} from "../utils/repo-status";
|
||||
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
|
||||
import type { TranscriptScrollHandle } from "./components/chat-message-list";
|
||||
import {
|
||||
@@ -36,9 +39,11 @@ import {
|
||||
SKILLS_MARKETPLACE_URL,
|
||||
SkillsPickerContent,
|
||||
} from "./components/dialogs/skills-picker";
|
||||
import { ThemePickerContent } from "./components/dialogs/theme-picker";
|
||||
import { Toast, type ToastState, type ToastVariant } from "./components/toast";
|
||||
import { EventBridgeProvider } from "./contexts/event-bridge-context";
|
||||
import { SessionProvider, useSession } from "./contexts/session-context";
|
||||
import { ThemeProvider } from "./hooks/theme-provider";
|
||||
import { useAccountDialog } from "./hooks/use-account-dialog";
|
||||
import { useAgentEventHandlers } from "./hooks/use-agent-events";
|
||||
import { useAutocomplete } from "./hooks/use-autocomplete";
|
||||
@@ -51,8 +56,8 @@ import { useQueuedPrompts } from "./hooks/use-queued-prompts";
|
||||
import { useRootKeyboard } from "./hooks/use-root-keyboard";
|
||||
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import { TerminalColorsContext } from "./hooks/use-theme";
|
||||
import type { AppView, TuiProps, TuiStartupTarget } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
@@ -135,12 +140,31 @@ function App(props: TuiProps) {
|
||||
skillCommands,
|
||||
});
|
||||
|
||||
const repoStatusInFlightRef = useRef(false);
|
||||
const refreshRepoStatus = useCallback(() => {
|
||||
// Skip if the previous read is still running (slow git on huge repos)
|
||||
// so poll ticks never stack subprocesses or apply stale results.
|
||||
if (repoStatusInFlightRef.current) return;
|
||||
repoStatusInFlightRef.current = true;
|
||||
readRepoStatus(props.config.cwd)
|
||||
.then(setRepoStatus)
|
||||
.catch(() => {});
|
||||
.then((next) =>
|
||||
// Keep the previous object when nothing changed so poll ticks
|
||||
// don't re-render the app.
|
||||
setRepoStatus((prev) => (isSameRepoStatus(prev, next) ? prev : next)),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
repoStatusInFlightRef.current = false;
|
||||
});
|
||||
}, [props.config.cwd]);
|
||||
|
||||
// Poll so branch switches made outside the CLI (another terminal, an
|
||||
// editor) show up without requiring an agent turn.
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refreshRepoStatus, 5_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshRepoStatus]);
|
||||
|
||||
const refocusTextareaRef = useRef<() => void>(() => {});
|
||||
const populateInputRef = useRef<(value: string) => void>(() => {});
|
||||
const insertSkillCommandRef = useRef<
|
||||
@@ -203,6 +227,22 @@ function App(props: TuiProps) {
|
||||
onSessionRestart: props.onSessionRestart,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
});
|
||||
|
||||
const openThemePicker = useCallback(
|
||||
async (options?: { refocus?: boolean }) => {
|
||||
await dialog.choice<string>({
|
||||
size: "large",
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
<ThemePickerContent {...ctx} />
|
||||
),
|
||||
});
|
||||
if (options?.refocus !== false) {
|
||||
refocusTextareaRef.current();
|
||||
}
|
||||
},
|
||||
[dialog, termHeight],
|
||||
);
|
||||
const propsOnToggleConfigItem = props.onToggleConfigItem;
|
||||
const onToggleConfigItem = useMemo<TuiProps["onToggleConfigItem"]>(() => {
|
||||
if (!propsOnToggleConfigItem) {
|
||||
@@ -244,6 +284,7 @@ function App(props: TuiProps) {
|
||||
onDeleteConfigItem,
|
||||
openModelSelector,
|
||||
openMcpManager,
|
||||
openThemePicker,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
});
|
||||
|
||||
@@ -592,6 +633,7 @@ function App(props: TuiProps) {
|
||||
openMcpManager,
|
||||
openModelSelector,
|
||||
openSkills,
|
||||
openThemePicker,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
setAppView,
|
||||
onClearConversation: clearConversation,
|
||||
@@ -914,6 +956,7 @@ export function Root(
|
||||
props: TuiProps & {
|
||||
terminalBackground?: string | null;
|
||||
terminalForeground?: string | null;
|
||||
initialThemeId?: string;
|
||||
},
|
||||
) {
|
||||
const initialEntries = useMemo(
|
||||
@@ -937,19 +980,21 @@ export function Root(
|
||||
);
|
||||
return (
|
||||
<TerminalColorsContext value={terminalColors}>
|
||||
<DialogProvider size="medium">
|
||||
<SessionProvider
|
||||
config={props.config}
|
||||
initialEntries={initialEntries}
|
||||
initialUsage={initialUsage}
|
||||
onRunningChange={props.onRunningChange}
|
||||
onAutoApproveChange={props.onAutoApproveChange}
|
||||
onCompactionModeChange={props.onCompactionModeChange}
|
||||
onExit={props.onExit}
|
||||
>
|
||||
<App {...props} />
|
||||
</SessionProvider>
|
||||
</DialogProvider>
|
||||
<ThemeProvider initialThemeId={props.initialThemeId}>
|
||||
<DialogProvider size="medium">
|
||||
<SessionProvider
|
||||
config={props.config}
|
||||
initialEntries={initialEntries}
|
||||
initialUsage={initialUsage}
|
||||
onRunningChange={props.onRunningChange}
|
||||
onAutoApproveChange={props.onAutoApproveChange}
|
||||
onCompactionModeChange={props.onCompactionModeChange}
|
||||
onExit={props.onExit}
|
||||
>
|
||||
<App {...props} />
|
||||
</SessionProvider>
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</TerminalColorsContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diffPalettes, themePalette } from "./palette";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
getDialogAccents,
|
||||
getThemeDefinition,
|
||||
getThemeModeAccent,
|
||||
getThemeSwatchColors,
|
||||
normalizeThemeId,
|
||||
resolveTheme,
|
||||
THEMES,
|
||||
} from "./themes";
|
||||
|
||||
const noDetection = { background: null, foreground: null };
|
||||
|
||||
describe("theme registry", () => {
|
||||
it("has unique ids and auto first", () => {
|
||||
const ids = THEMES.map((theme) => theme.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids[0]).toBe(AUTO_THEME_ID);
|
||||
});
|
||||
|
||||
it("gives every non-auto theme an explicit background and foreground", () => {
|
||||
for (const theme of THEMES) {
|
||||
if (theme.id === AUTO_THEME_ID) {
|
||||
expect(theme.background).toBeNull();
|
||||
expect(theme.foreground).toBeNull();
|
||||
} else {
|
||||
expect(theme.background).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(theme.foreground).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("provides four swatch colors per theme", () => {
|
||||
for (const theme of THEMES) {
|
||||
expect(getThemeSwatchColors(theme)).toHaveLength(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeThemeId", () => {
|
||||
it("accepts known ids case-insensitively", () => {
|
||||
expect(normalizeThemeId("tokyo-night")).toBe("tokyo-night");
|
||||
expect(normalizeThemeId(" Dracula ")).toBe("dracula");
|
||||
});
|
||||
|
||||
it("falls back to auto for unknown or missing ids", () => {
|
||||
expect(normalizeThemeId("not-a-theme")).toBe(AUTO_THEME_ID);
|
||||
expect(normalizeThemeId(undefined)).toBe(AUTO_THEME_ID);
|
||||
expect(normalizeThemeId(null)).toBe(AUTO_THEME_ID);
|
||||
expect(normalizeThemeId("")).toBe(AUTO_THEME_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
it("auto adapts to the detected terminal and keeps its background", () => {
|
||||
const dark = resolveTheme(AUTO_THEME_ID, noDetection);
|
||||
expect(dark.variant).toBe("dark");
|
||||
expect(dark.appBackground).toBeNull();
|
||||
expect(dark.background).toBeNull();
|
||||
expect(dark.defaultForeground).toBeUndefined();
|
||||
expect(dark.accents.act).toBe(themePalette.dark.act);
|
||||
expect(dark.diff).toEqual(diffPalettes.dark);
|
||||
|
||||
const light = resolveTheme(AUTO_THEME_ID, {
|
||||
background: "#ffffff",
|
||||
foreground: null,
|
||||
});
|
||||
expect(light.variant).toBe("light");
|
||||
expect(light.appBackground).toBeNull();
|
||||
expect(light.background).toBe("#ffffff");
|
||||
expect(light.defaultForeground).toBe("#1a1a1a");
|
||||
expect(light.accents.act).toBe(themePalette.light.act);
|
||||
expect(light.diff).toEqual(diffPalettes.light);
|
||||
});
|
||||
|
||||
it("forced dark and light themes override detection", () => {
|
||||
const forcedDark = resolveTheme("dark", {
|
||||
background: "#ffffff",
|
||||
foreground: "#1a1a1a",
|
||||
});
|
||||
expect(forcedDark.variant).toBe("dark");
|
||||
expect(forcedDark.appBackground).toBe("#14161b");
|
||||
expect(forcedDark.background).toBe("#14161b");
|
||||
expect(forcedDark.accents.act).toBe(themePalette.dark.act);
|
||||
|
||||
const forcedLight = resolveTheme("light", noDetection);
|
||||
expect(forcedLight.variant).toBe("light");
|
||||
expect(forcedLight.appBackground).toBe("#ffffff");
|
||||
expect(forcedLight.defaultForeground).toBe("#1a1a1a");
|
||||
expect(forcedLight.accents.act).toBe(themePalette.light.act);
|
||||
});
|
||||
|
||||
it("named themes carry their own accents, syntax, and derived diff", () => {
|
||||
const tokyo = resolveTheme("tokyo-night", noDetection);
|
||||
expect(tokyo.variant).toBe("dark");
|
||||
expect(tokyo.appBackground).toBe("#1a1b26");
|
||||
expect(tokyo.defaultForeground).toBe("#c0caf5");
|
||||
expect(tokyo.accents.act).toBe("#7aa2f7");
|
||||
expect(tokyo.syntax.keyword).toBe("#bb9af7");
|
||||
expect(tokyo.diff.addedSignColor).toBe("#9ece6a");
|
||||
expect(tokyo.diff.removedSignColor).toBe("#f7768e");
|
||||
// Derived diff backgrounds are tints of the theme background, not the
|
||||
// stock dark diff palette.
|
||||
expect(tokyo.diff.addedBg).not.toBe(diffPalettes.dark.addedBg);
|
||||
expect(tokyo.diff.addedBg).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
|
||||
it("falls back to auto for unknown ids", () => {
|
||||
const resolved = resolveTheme("bogus", noDetection);
|
||||
expect(resolved.id).toBe(AUTO_THEME_ID);
|
||||
});
|
||||
|
||||
it("resolves every registered theme without missing colors", () => {
|
||||
for (const definition of THEMES) {
|
||||
const resolved = resolveTheme(definition.id, noDetection);
|
||||
expect(resolved.accents.act).toBeTruthy();
|
||||
expect(resolved.accents.plan).toBeTruthy();
|
||||
expect(resolved.accents.success).toBeTruthy();
|
||||
expect(resolved.accents.error).toBeTruthy();
|
||||
for (const value of Object.values(resolved.diff)) {
|
||||
expect(value).toBeTruthy();
|
||||
}
|
||||
expect(resolved.syntax.keyword).toBeTruthy();
|
||||
expect(resolved.syntax.comment).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("derives a readable selection pair per theme", () => {
|
||||
// Dark-theme accents are light, so selected text flips to black.
|
||||
const dark = resolveTheme(AUTO_THEME_ID, noDetection);
|
||||
expect(dark.selection).toBe(dark.accents.act);
|
||||
expect(dark.textOnSelection).toBe("#000000");
|
||||
|
||||
// Light-theme accents are darkened for contrast, so text flips to white.
|
||||
const light = resolveTheme("light", noDetection);
|
||||
expect(light.selection).toBe(light.accents.act);
|
||||
expect(light.textOnSelection).toBe("#ffffff");
|
||||
|
||||
for (const definition of THEMES) {
|
||||
const resolved = resolveTheme(definition.id, noDetection);
|
||||
expect(["#000000", "#ffffff"]).toContain(resolved.textOnSelection);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme helpers", () => {
|
||||
it("getThemeModeAccent picks the accent by mode", () => {
|
||||
const theme = resolveTheme("nord", noDetection);
|
||||
expect(getThemeModeAccent(theme, "act")).toBe("#88c0d0");
|
||||
expect(getThemeModeAccent(theme, "plan")).toBe("#ebcb8b");
|
||||
});
|
||||
|
||||
it("getDialogAccents falls back to dark accents for light themes", () => {
|
||||
const solarizedLight = resolveTheme("solarized-light", noDetection);
|
||||
expect(getDialogAccents(solarizedLight).act).toBe(themePalette.dark.act);
|
||||
|
||||
const dracula = resolveTheme("dracula", noDetection);
|
||||
expect(getDialogAccents(dracula).act).toBe("#bd93f9");
|
||||
});
|
||||
|
||||
it("getThemeDefinition finds registered themes", () => {
|
||||
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
|
||||
expect(getThemeDefinition("missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import {
|
||||
diffPalettes,
|
||||
getDefaultForeground,
|
||||
getTerminalTheme,
|
||||
hexToOklab,
|
||||
oklabToHex,
|
||||
type TerminalTheme,
|
||||
themePalette,
|
||||
} from "./palette";
|
||||
|
||||
// User-selectable color themes for the TUI.
|
||||
//
|
||||
// Three kinds of built-in themes exist:
|
||||
// - "auto" adapts to the terminal: it detects light/dark from the terminal's
|
||||
// reported background and keeps that background untouched (the pre-theme
|
||||
// behavior, and still the default).
|
||||
// - "dark" / "light" force the corresponding Cline palette and paint a
|
||||
// matching background, for terminals whose reported colors are missing or
|
||||
// wrong (see cline/cline#12872).
|
||||
// - Named themes (Tokyo Night, Gruvbox, ...) paint their canonical
|
||||
// background and bring their own accent + syntax palettes.
|
||||
|
||||
export interface ThemeAccents {
|
||||
act: string;
|
||||
plan: string;
|
||||
success: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ThemeDiffPalette {
|
||||
addedBg: string;
|
||||
removedBg: string;
|
||||
addedLineNumberBg: string;
|
||||
removedLineNumberBg: string;
|
||||
addedSignColor: string;
|
||||
removedSignColor: string;
|
||||
lineNumberFg: string;
|
||||
}
|
||||
|
||||
export interface ThemeSyntaxColors {
|
||||
keyword: string;
|
||||
operator: string;
|
||||
type: string;
|
||||
functionName: string;
|
||||
variable: string;
|
||||
string: string;
|
||||
number: string;
|
||||
comment: string;
|
||||
punctuation: string;
|
||||
property: string;
|
||||
constant: string;
|
||||
tag: string;
|
||||
attribute: string;
|
||||
escape: string;
|
||||
markdownCode: string;
|
||||
markdownMuted: string;
|
||||
markdownItalic: string;
|
||||
markdownDefault?: string;
|
||||
}
|
||||
|
||||
// Dark syntax colors are a pastel family harmonized with the brand accents
|
||||
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
|
||||
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
|
||||
// part of the same palette instead of a bolted-on editor theme.
|
||||
export const baseSyntaxColors: Record<TerminalTheme, ThemeSyntaxColors> = {
|
||||
dark: {
|
||||
keyword: "#d7a0e3",
|
||||
operator: "#9bbbdd",
|
||||
type: "#dfca7d",
|
||||
functionName: themePalette.dark.act,
|
||||
variable: "#ee939b",
|
||||
string: "#99e89b",
|
||||
number: "#f0ad7f",
|
||||
comment: "#5c6370",
|
||||
punctuation: "#abb2bf",
|
||||
property: "#ee939b",
|
||||
constant: "#f0ad7f",
|
||||
tag: "#ee939b",
|
||||
attribute: "#f0ad7f",
|
||||
escape: "#9bbbdd",
|
||||
markdownCode: "#99e89b",
|
||||
markdownMuted: "#808080",
|
||||
markdownItalic: "#dfca7d",
|
||||
},
|
||||
light: {
|
||||
keyword: "#cf222e",
|
||||
operator: "#0550ae",
|
||||
type: "#953800",
|
||||
functionName: "#8250df",
|
||||
variable: "#953800",
|
||||
string: "#0a3069",
|
||||
number: "#0550ae",
|
||||
comment: "#6e7781",
|
||||
punctuation: "#57606a",
|
||||
property: "#0550ae",
|
||||
constant: "#0550ae",
|
||||
tag: "#116329",
|
||||
attribute: "#0550ae",
|
||||
escape: "#0550ae",
|
||||
markdownCode: "#116329",
|
||||
markdownMuted: "#6e7781",
|
||||
markdownItalic: "#8250df",
|
||||
markdownDefault: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
|
||||
const baseAccents: Record<TerminalTheme, ThemeAccents> = {
|
||||
dark: { ...themePalette.dark, error: "#ef4444" },
|
||||
light: { ...themePalette.light, error: "#b42318" },
|
||||
};
|
||||
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** "auto" resolves to the detected terminal variant at runtime. */
|
||||
variant: TerminalTheme | "auto";
|
||||
/** Painted over the whole terminal; null keeps the terminal's background. */
|
||||
background: string | null;
|
||||
/** Default text color; null keeps the variant default. */
|
||||
foreground: string | null;
|
||||
accents?: Partial<ThemeAccents>;
|
||||
syntax?: Partial<ThemeSyntaxColors>;
|
||||
diff?: Partial<ThemeDiffPalette>;
|
||||
}
|
||||
|
||||
export interface ResolvedTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
variant: TerminalTheme;
|
||||
/** Explicit background painted over the terminal, or null to keep it. */
|
||||
appBackground: string | null;
|
||||
/** Background all adaptive colors derive from (theme's, else detected). */
|
||||
background: string | null;
|
||||
/** Default text color; undefined keeps the renderer default (white). */
|
||||
defaultForeground: string | undefined;
|
||||
/** Background for selected rows/buttons on the main themed surface. */
|
||||
selection: string;
|
||||
/** Text color readable on top of `selection`. */
|
||||
textOnSelection: string;
|
||||
accents: ThemeAccents;
|
||||
diff: ThemeDiffPalette;
|
||||
syntax: ThemeSyntaxColors;
|
||||
}
|
||||
|
||||
export const AUTO_THEME_ID = "auto";
|
||||
|
||||
export const THEMES: readonly ThemeDefinition[] = [
|
||||
{
|
||||
id: AUTO_THEME_ID,
|
||||
label: "Auto",
|
||||
description: "Adapts to your terminal's colors",
|
||||
variant: "auto",
|
||||
background: null,
|
||||
foreground: null,
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: "Cline Dark",
|
||||
description: "Cline's accents on deep charcoal",
|
||||
variant: "dark",
|
||||
background: "#14161b",
|
||||
foreground: "#e8eaed",
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
label: "Cline Light",
|
||||
description: "Crisp white, high-contrast accents",
|
||||
variant: "light",
|
||||
background: "#ffffff",
|
||||
foreground: "#1a1a1a",
|
||||
},
|
||||
{
|
||||
id: "tokyo-night",
|
||||
label: "Tokyo Night",
|
||||
description: "Moody blues and neon city glow",
|
||||
variant: "dark",
|
||||
background: "#1a1b26",
|
||||
foreground: "#c0caf5",
|
||||
accents: {
|
||||
act: "#7aa2f7",
|
||||
plan: "#e0af68",
|
||||
success: "#9ece6a",
|
||||
error: "#f7768e",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#bb9af7",
|
||||
operator: "#89ddff",
|
||||
type: "#2ac3de",
|
||||
functionName: "#7aa2f7",
|
||||
variable: "#c0caf5",
|
||||
string: "#9ece6a",
|
||||
number: "#ff9e64",
|
||||
comment: "#565f89",
|
||||
punctuation: "#a9b1d6",
|
||||
property: "#73daca",
|
||||
constant: "#ff9e64",
|
||||
tag: "#f7768e",
|
||||
attribute: "#bb9af7",
|
||||
escape: "#89ddff",
|
||||
markdownCode: "#9ece6a",
|
||||
markdownMuted: "#565f89",
|
||||
markdownItalic: "#e0af68",
|
||||
markdownDefault: "#c0caf5",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gruvbox-dark",
|
||||
label: "Gruvbox Dark",
|
||||
description: "Retro warmth, earthy and amber",
|
||||
variant: "dark",
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
accents: {
|
||||
act: "#83a598",
|
||||
plan: "#fabd2f",
|
||||
success: "#b8bb26",
|
||||
error: "#fb4934",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#fb4934",
|
||||
operator: "#fe8019",
|
||||
type: "#fabd2f",
|
||||
functionName: "#b8bb26",
|
||||
variable: "#83a598",
|
||||
string: "#b8bb26",
|
||||
number: "#d3869b",
|
||||
comment: "#928374",
|
||||
punctuation: "#ebdbb2",
|
||||
property: "#83a598",
|
||||
constant: "#d3869b",
|
||||
tag: "#8ec07c",
|
||||
attribute: "#fabd2f",
|
||||
escape: "#fe8019",
|
||||
markdownCode: "#b8bb26",
|
||||
markdownMuted: "#928374",
|
||||
markdownItalic: "#fabd2f",
|
||||
markdownDefault: "#ebdbb2",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
label: "Nord",
|
||||
description: "Cool arctic blues and frosted teals",
|
||||
variant: "dark",
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
accents: {
|
||||
act: "#88c0d0",
|
||||
plan: "#ebcb8b",
|
||||
success: "#a3be8c",
|
||||
error: "#bf616a",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#81a1c1",
|
||||
operator: "#81a1c1",
|
||||
type: "#8fbcbb",
|
||||
functionName: "#88c0d0",
|
||||
variable: "#d8dee9",
|
||||
string: "#a3be8c",
|
||||
number: "#b48ead",
|
||||
comment: "#616e88",
|
||||
punctuation: "#eceff4",
|
||||
property: "#8fbcbb",
|
||||
constant: "#b48ead",
|
||||
tag: "#81a1c1",
|
||||
attribute: "#8fbcbb",
|
||||
escape: "#ebcb8b",
|
||||
markdownCode: "#a3be8c",
|
||||
markdownMuted: "#616e88",
|
||||
markdownItalic: "#ebcb8b",
|
||||
markdownDefault: "#d8dee9",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
label: "Dracula",
|
||||
description: "Vivid color on a dark violet night",
|
||||
variant: "dark",
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
accents: {
|
||||
act: "#bd93f9",
|
||||
plan: "#f1fa8c",
|
||||
success: "#50fa7b",
|
||||
error: "#ff5555",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#ff79c6",
|
||||
operator: "#ff79c6",
|
||||
type: "#8be9fd",
|
||||
functionName: "#50fa7b",
|
||||
variable: "#f8f8f2",
|
||||
string: "#f1fa8c",
|
||||
number: "#bd93f9",
|
||||
comment: "#6272a4",
|
||||
punctuation: "#f8f8f2",
|
||||
property: "#8be9fd",
|
||||
constant: "#bd93f9",
|
||||
tag: "#ff79c6",
|
||||
attribute: "#50fa7b",
|
||||
escape: "#ff79c6",
|
||||
markdownCode: "#f1fa8c",
|
||||
markdownMuted: "#6272a4",
|
||||
markdownItalic: "#ffb86c",
|
||||
markdownDefault: "#f8f8f2",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "catppuccin-mocha",
|
||||
label: "Catppuccin Mocha",
|
||||
description: "Soothing pastels on warm mocha",
|
||||
variant: "dark",
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
accents: {
|
||||
act: "#89b4fa",
|
||||
plan: "#f9e2af",
|
||||
success: "#a6e3a1",
|
||||
error: "#f38ba8",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#cba6f7",
|
||||
operator: "#89dceb",
|
||||
type: "#f9e2af",
|
||||
functionName: "#89b4fa",
|
||||
variable: "#cdd6f4",
|
||||
string: "#a6e3a1",
|
||||
number: "#fab387",
|
||||
comment: "#6c7086",
|
||||
punctuation: "#9399b2",
|
||||
property: "#94e2d5",
|
||||
constant: "#fab387",
|
||||
tag: "#f38ba8",
|
||||
attribute: "#f9e2af",
|
||||
escape: "#f5c2e7",
|
||||
markdownCode: "#a6e3a1",
|
||||
markdownMuted: "#6c7086",
|
||||
markdownItalic: "#f9e2af",
|
||||
markdownDefault: "#cdd6f4",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "one-dark",
|
||||
label: "One Dark",
|
||||
description: "Atom's balanced, easygoing dark",
|
||||
variant: "dark",
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
accents: {
|
||||
act: "#61afef",
|
||||
plan: "#e5c07b",
|
||||
success: "#98c379",
|
||||
error: "#e06c75",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#c678dd",
|
||||
operator: "#56b6c2",
|
||||
type: "#e5c07b",
|
||||
functionName: "#61afef",
|
||||
variable: "#e06c75",
|
||||
string: "#98c379",
|
||||
number: "#d19a66",
|
||||
comment: "#5c6370",
|
||||
punctuation: "#abb2bf",
|
||||
property: "#e06c75",
|
||||
constant: "#d19a66",
|
||||
tag: "#e06c75",
|
||||
attribute: "#d19a66",
|
||||
escape: "#56b6c2",
|
||||
markdownCode: "#98c379",
|
||||
markdownMuted: "#5c6370",
|
||||
markdownItalic: "#e5c07b",
|
||||
markdownDefault: "#abb2bf",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "solarized-dark",
|
||||
label: "Solarized Dark",
|
||||
description: "Low-glare teal depths, easy on eyes",
|
||||
variant: "dark",
|
||||
background: "#002b36",
|
||||
foreground: "#93a1a1",
|
||||
accents: {
|
||||
act: "#268bd2",
|
||||
plan: "#b58900",
|
||||
success: "#859900",
|
||||
error: "#dc322f",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#859900",
|
||||
operator: "#93a1a1",
|
||||
type: "#b58900",
|
||||
functionName: "#268bd2",
|
||||
variable: "#268bd2",
|
||||
string: "#2aa198",
|
||||
number: "#d33682",
|
||||
comment: "#586e75",
|
||||
punctuation: "#93a1a1",
|
||||
property: "#268bd2",
|
||||
constant: "#cb4b16",
|
||||
tag: "#268bd2",
|
||||
attribute: "#93a1a1",
|
||||
escape: "#cb4b16",
|
||||
markdownCode: "#2aa198",
|
||||
markdownMuted: "#586e75",
|
||||
markdownItalic: "#6c71c4",
|
||||
markdownDefault: "#93a1a1",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "solarized-light",
|
||||
label: "Solarized Light",
|
||||
description: "Warm parchment with muted accents",
|
||||
variant: "light",
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
accents: {
|
||||
act: "#268bd2",
|
||||
plan: "#b58900",
|
||||
success: "#859900",
|
||||
error: "#dc322f",
|
||||
},
|
||||
syntax: {
|
||||
keyword: "#859900",
|
||||
operator: "#657b83",
|
||||
type: "#b58900",
|
||||
functionName: "#268bd2",
|
||||
variable: "#268bd2",
|
||||
string: "#2aa198",
|
||||
number: "#d33682",
|
||||
comment: "#93a1a1",
|
||||
punctuation: "#657b83",
|
||||
property: "#268bd2",
|
||||
constant: "#cb4b16",
|
||||
tag: "#268bd2",
|
||||
attribute: "#586e75",
|
||||
escape: "#cb4b16",
|
||||
markdownCode: "#2aa198",
|
||||
markdownMuted: "#93a1a1",
|
||||
markdownItalic: "#6c71c4",
|
||||
markdownDefault: "#657b83",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
const THEMES_BY_ID = new Map(THEMES.map((theme) => [theme.id, theme]));
|
||||
|
||||
export function getThemeDefinition(id: string): ThemeDefinition | undefined {
|
||||
return THEMES_BY_ID.get(id);
|
||||
}
|
||||
|
||||
export function normalizeThemeId(id: string | undefined | null): string {
|
||||
const trimmed = id?.trim().toLowerCase();
|
||||
return trimmed && THEMES_BY_ID.has(trimmed) ? trimmed : AUTO_THEME_ID;
|
||||
}
|
||||
|
||||
// Below this WCAG relative luminance, white text has the higher contrast
|
||||
// ratio against the background; above it, black does. Derived from
|
||||
// (L + 0.05)^2 = 1.05 * 0.05.
|
||||
const WHITE_TEXT_LUMINANCE_CUTOFF = 0.179;
|
||||
|
||||
function relativeLuminance(hex: string): number {
|
||||
const channel = (offset: number) => {
|
||||
const c = parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||
}
|
||||
|
||||
function mixHex(base: string, tint: string, amount: number): string {
|
||||
const a = hexToOklab(base);
|
||||
const b = hexToOklab(tint);
|
||||
return oklabToHex(
|
||||
a.L + (b.L - a.L) * amount,
|
||||
a.a + (b.a - a.a) * amount,
|
||||
a.b + (b.b - a.b) * amount,
|
||||
);
|
||||
}
|
||||
|
||||
// Diff rows tint the theme background toward the theme's own green/red so
|
||||
// diffs feel native to every theme instead of reusing one fixed palette.
|
||||
function deriveDiffPalette(
|
||||
background: string,
|
||||
foreground: string,
|
||||
accents: ThemeAccents,
|
||||
): ThemeDiffPalette {
|
||||
return {
|
||||
addedBg: mixHex(background, accents.success, 0.22),
|
||||
removedBg: mixHex(background, accents.error, 0.22),
|
||||
addedLineNumberBg: mixHex(background, accents.success, 0.3),
|
||||
removedLineNumberBg: mixHex(background, accents.error, 0.3),
|
||||
addedSignColor: accents.success,
|
||||
removedSignColor: accents.error,
|
||||
lineNumberFg: mixHex(foreground, background, 0.4),
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetectedTerminalColors {
|
||||
background: string | null;
|
||||
foreground: string | null;
|
||||
}
|
||||
|
||||
export function resolveTheme(
|
||||
id: string,
|
||||
detected: DetectedTerminalColors,
|
||||
): ResolvedTheme {
|
||||
const definition =
|
||||
getThemeDefinition(normalizeThemeId(id)) ?? (THEMES[0] as ThemeDefinition);
|
||||
const variant: TerminalTheme =
|
||||
definition.variant === "auto"
|
||||
? getTerminalTheme(detected.background, detected.foreground)
|
||||
: definition.variant;
|
||||
const appBackground = definition.background;
|
||||
const background = appBackground ?? detected.background;
|
||||
const accents: ThemeAccents = {
|
||||
...baseAccents[variant],
|
||||
...definition.accents,
|
||||
};
|
||||
const syntax: ThemeSyntaxColors = {
|
||||
...baseSyntaxColors[variant],
|
||||
...(definition.foreground
|
||||
? { markdownDefault: definition.foreground }
|
||||
: {}),
|
||||
...definition.syntax,
|
||||
};
|
||||
const diff: ThemeDiffPalette = {
|
||||
...(appBackground && definition.foreground
|
||||
? deriveDiffPalette(appBackground, definition.foreground, accents)
|
||||
: diffPalettes[variant]),
|
||||
...definition.diff,
|
||||
};
|
||||
// Selected rows highlight with the act accent; the text on top flips
|
||||
// between black and white, picking whichever has the higher WCAG
|
||||
// contrast ratio against the accent.
|
||||
const selection = accents.act;
|
||||
const textOnSelection =
|
||||
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
return {
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
variant,
|
||||
appBackground,
|
||||
background,
|
||||
defaultForeground:
|
||||
definition.foreground ?? getDefaultForeground(background),
|
||||
selection,
|
||||
textOnSelection,
|
||||
accents,
|
||||
diff,
|
||||
syntax,
|
||||
};
|
||||
}
|
||||
|
||||
export function getThemeModeAccent(theme: ResolvedTheme, mode: string): string {
|
||||
return mode === "plan" ? theme.accents.plan : theme.accents.act;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog surfaces are always dark, so light-variant themes (whose accents are
|
||||
* darkened for contrast on light backgrounds) fall back to the dark accent
|
||||
* set for readable dialog content.
|
||||
*/
|
||||
export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
|
||||
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
|
||||
}
|
||||
|
||||
/** Small color strip rendered next to each entry in the theme picker. */
|
||||
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
|
||||
const variant = definition.variant === "auto" ? "dark" : definition.variant;
|
||||
const accents = { ...baseAccents[variant], ...definition.accents };
|
||||
return [accents.act, accents.plan, accents.success, accents.error];
|
||||
}
|
||||
@@ -135,6 +135,35 @@ describe("hydrateSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/13036:
|
||||
// persisted sessions with malformed tool inputs must stay resumable.
|
||||
it("hydrates tool calls with malformed inputs without throwing", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "run_commands",
|
||||
input: { command: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolName: "run_commands",
|
||||
inputSummary: "",
|
||||
rawInput: { command: null },
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves mode undefined for transcripts without user_input wrappers", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "plain old message" },
|
||||
|
||||
@@ -39,13 +39,25 @@ vi.mock("@opentui/core", () => ({
|
||||
SyntaxStyle: MockSyntaxStyle,
|
||||
}));
|
||||
|
||||
import { resolveTheme } from "../themes";
|
||||
|
||||
const darkTheme = resolveTheme("auto", { background: null, foreground: null });
|
||||
const lightTheme = resolveTheme("auto", {
|
||||
background: "#ffffff",
|
||||
foreground: null,
|
||||
});
|
||||
const tokyoNight = resolveTheme("tokyo-night", {
|
||||
background: null,
|
||||
foreground: null,
|
||||
});
|
||||
|
||||
describe("getSyntaxStyle", () => {
|
||||
it("keeps dark markdown prose on the terminal default foreground", () => {
|
||||
expect(getSyntaxStyle("dark").getStyle("default")).toBeUndefined();
|
||||
expect(getSyntaxStyle(darkTheme).getStyle("default")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a dark default foreground for light markdown content", () => {
|
||||
const style = getSyntaxStyle("light").getStyle("default");
|
||||
const style = getSyntaxStyle(lightTheme).getStyle("default");
|
||||
|
||||
expect(style?.fg?.toInts()).toEqual([26, 26, 26, 255]);
|
||||
});
|
||||
@@ -53,29 +65,47 @@ describe("getSyntaxStyle", () => {
|
||||
it("tints markdown accents by mode", () => {
|
||||
// act #79b8ff vs plan #ffea7f (dark theme accents)
|
||||
expect(
|
||||
getSyntaxStyle("dark", "act").getStyle("markup.heading")?.fg?.toInts(),
|
||||
getSyntaxStyle(darkTheme, "act").getStyle("markup.heading")?.fg?.toInts(),
|
||||
).toEqual([0x79, 0xb8, 0xff, 255]);
|
||||
expect(
|
||||
getSyntaxStyle("dark", "plan").getStyle("markup.heading")?.fg?.toInts(),
|
||||
getSyntaxStyle(darkTheme, "plan")
|
||||
.getStyle("markup.heading")
|
||||
?.fg?.toInts(),
|
||||
).toEqual([0xff, 0xea, 0x7f, 255]);
|
||||
expect(
|
||||
getSyntaxStyle("dark", "plan").getStyle("markup.link")?.fg?.toInts(),
|
||||
getSyntaxStyle(darkTheme, "plan").getStyle("markup.link")?.fg?.toInts(),
|
||||
).toEqual([0xff, 0xea, 0x7f, 255]);
|
||||
});
|
||||
|
||||
it("tints light-theme markdown accents by mode", () => {
|
||||
// act #0f72cb vs plan #867100 (light theme accents)
|
||||
expect(
|
||||
getSyntaxStyle("light", "act").getStyle("markup.heading")?.fg?.toInts(),
|
||||
getSyntaxStyle(lightTheme, "act")
|
||||
.getStyle("markup.heading")
|
||||
?.fg?.toInts(),
|
||||
).toEqual([0x0f, 0x72, 0xcb, 255]);
|
||||
expect(
|
||||
getSyntaxStyle("light", "plan").getStyle("markup.heading")?.fg?.toInts(),
|
||||
getSyntaxStyle(lightTheme, "plan")
|
||||
.getStyle("markup.heading")
|
||||
?.fg?.toInts(),
|
||||
).toEqual([0x86, 0x71, 0x00, 255]);
|
||||
});
|
||||
|
||||
it("keeps code token colors constant across modes", () => {
|
||||
expect(getSyntaxStyle("dark", "plan").getStyle("keyword")).toEqual(
|
||||
getSyntaxStyle("dark", "act").getStyle("keyword"),
|
||||
expect(getSyntaxStyle(darkTheme, "plan").getStyle("keyword")).toEqual(
|
||||
getSyntaxStyle(darkTheme, "act").getStyle("keyword"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses named theme syntax palettes and accents", () => {
|
||||
// Tokyo Night keyword #bb9af7, act accent #7aa2f7.
|
||||
expect(
|
||||
getSyntaxStyle(tokyoNight, "act").getStyle("keyword")?.fg?.toInts(),
|
||||
).toEqual([0xbb, 0x9a, 0xf7, 255]);
|
||||
expect(
|
||||
getSyntaxStyle(tokyoNight, "act")
|
||||
.getStyle("markup.heading")
|
||||
?.fg?.toInts(),
|
||||
).toEqual([0x7a, 0xa2, 0xf7, 255]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
|
||||
import { type TerminalTheme, themePalette } from "../palette";
|
||||
import type { ResolvedTheme } from "../themes";
|
||||
|
||||
// Markdown's prominent elements (headings, bold, list markers, links) take
|
||||
// the accent of the mode the content was produced in, so assistant output
|
||||
@@ -8,73 +8,6 @@ export type SyntaxAccentMode = "act" | "plan";
|
||||
|
||||
const instances = new Map<string, SyntaxStyle>();
|
||||
|
||||
interface SyntaxColors {
|
||||
keyword: string;
|
||||
operator: string;
|
||||
type: string;
|
||||
functionName: string;
|
||||
variable: string;
|
||||
string: string;
|
||||
number: string;
|
||||
comment: string;
|
||||
punctuation: string;
|
||||
property: string;
|
||||
constant: string;
|
||||
tag: string;
|
||||
attribute: string;
|
||||
escape: string;
|
||||
markdownCode: string;
|
||||
markdownMuted: string;
|
||||
markdownItalic: string;
|
||||
markdownDefault?: string;
|
||||
}
|
||||
|
||||
// Dark syntax colors are a pastel family harmonized with the brand accents
|
||||
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
|
||||
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
|
||||
// part of the same palette instead of a bolted-on editor theme.
|
||||
const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
|
||||
dark: {
|
||||
keyword: "#d7a0e3",
|
||||
operator: "#9bbbdd",
|
||||
type: "#dfca7d",
|
||||
functionName: themePalette.dark.act,
|
||||
variable: "#ee939b",
|
||||
string: "#99e89b",
|
||||
number: "#f0ad7f",
|
||||
comment: "#5c6370",
|
||||
punctuation: "#abb2bf",
|
||||
property: "#ee939b",
|
||||
constant: "#f0ad7f",
|
||||
tag: "#ee939b",
|
||||
attribute: "#f0ad7f",
|
||||
escape: "#9bbbdd",
|
||||
markdownCode: "#99e89b",
|
||||
markdownMuted: "#808080",
|
||||
markdownItalic: "#dfca7d",
|
||||
},
|
||||
light: {
|
||||
keyword: "#cf222e",
|
||||
operator: "#0550ae",
|
||||
type: "#953800",
|
||||
functionName: "#8250df",
|
||||
variable: "#953800",
|
||||
string: "#0a3069",
|
||||
number: "#0550ae",
|
||||
comment: "#6e7781",
|
||||
punctuation: "#57606a",
|
||||
property: "#0550ae",
|
||||
constant: "#0550ae",
|
||||
tag: "#116329",
|
||||
attribute: "#0550ae",
|
||||
escape: "#0550ae",
|
||||
markdownCode: "#116329",
|
||||
markdownMuted: "#6e7781",
|
||||
markdownItalic: "#8250df",
|
||||
markdownDefault: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
|
||||
function color(hex: string): RGBA {
|
||||
return RGBA.fromHex(hex);
|
||||
}
|
||||
@@ -92,11 +25,13 @@ function italic(hex: string): StyleDefinition {
|
||||
}
|
||||
|
||||
function buildSyntaxStyle(
|
||||
theme: TerminalTheme,
|
||||
theme: ResolvedTheme,
|
||||
mode: SyntaxAccentMode,
|
||||
): SyntaxStyle {
|
||||
const colors = syntaxColors[theme];
|
||||
const accent = color(themePalette[theme][mode]);
|
||||
const colors = theme.syntax;
|
||||
const accent = color(
|
||||
mode === "plan" ? theme.accents.plan : theme.accents.act,
|
||||
);
|
||||
const markdownHeading = accent;
|
||||
const markdownCode = color(colors.markdownCode);
|
||||
const markdownMuted = color(colors.markdownMuted);
|
||||
@@ -150,10 +85,12 @@ function buildSyntaxStyle(
|
||||
}
|
||||
|
||||
export function getSyntaxStyle(
|
||||
theme: TerminalTheme = "dark",
|
||||
theme: ResolvedTheme,
|
||||
mode: SyntaxAccentMode = "act",
|
||||
): SyntaxStyle {
|
||||
const key = `${theme}:${mode}`;
|
||||
// The auto theme resolves to a different palette per variant, so the
|
||||
// variant participates in the cache key alongside the theme id.
|
||||
const key = `${theme.id}:${theme.variant}:${mode}`;
|
||||
let style = instances.get(key);
|
||||
if (!style) {
|
||||
style = buildSyntaxStyle(theme, mode);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
extractFullOutputText,
|
||||
parseReadFilesInput,
|
||||
} from "./tool-parsing";
|
||||
|
||||
describe("buildReadFilesKeys", () => {
|
||||
it("produces unique keys when the same path is read twice", () => {
|
||||
@@ -32,3 +36,64 @@ describe("buildReadFilesKeys", () => {
|
||||
expect(buildReadFilesKeys([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFullOutputText", () => {
|
||||
it("extracts text with real newlines from the MCP CallToolResult shape", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "text", text: "# Memory\n\nline one" },
|
||||
{ type: "text", text: "line two" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe("# Memory\n\nline one\nline two");
|
||||
});
|
||||
|
||||
it("keeps binary payloads behind placeholders in mixed MCP content", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "text", text: "before" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///a.md", blob: "d29ybGQ=" },
|
||||
},
|
||||
{ type: "resource_link", uri: "file:///b.md", name: "b.md" },
|
||||
{ type: "text", text: "after" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe(
|
||||
"before\n[image: image/png]\naGVsbG8=\n[resource: file:///a.md]\nd29ybGQ=\n[resource_link: file:///b.md]\nafter",
|
||||
);
|
||||
});
|
||||
|
||||
it("chunks base64 payloads into 76-char lines so collapse stays compact", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "image", data: "A".repeat(160), mimeType: "image/png" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)?.split("\n")).toEqual([
|
||||
"[image: image/png]",
|
||||
"A".repeat(76),
|
||||
"A".repeat(76),
|
||||
"A".repeat(8),
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts embedded resource text from MCP content", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///memory.md", text: "resource body\nline 2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe("resource body\nline 2");
|
||||
});
|
||||
|
||||
it("falls back to pretty JSON for objects without text content", () => {
|
||||
const raw = { structuredContent: { ok: true } };
|
||||
expect(extractFullOutputText(raw)).toBe(JSON.stringify(raw, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,12 @@ export function parseSpawnAgentInput(
|
||||
return { task: input.task };
|
||||
}
|
||||
|
||||
// Base64 payloads are one giant line; chunk to MIME width so GenericOutput's
|
||||
// line-based collapse stays compact and expand shows the full data.
|
||||
function chunkBase64(data: string): string {
|
||||
return data.match(/.{1,76}/g)?.join("\n") ?? data;
|
||||
}
|
||||
|
||||
export function extractFullOutputText(raw: unknown): string | undefined {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
if (typeof raw === "string") return raw;
|
||||
@@ -213,6 +219,51 @@ export function extractFullOutputText(raw: unknown): string | undefined {
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
// MCP tools return {content: [{type: "text", text}, ...]}. Extract the
|
||||
// text so multi-line results keep real newlines instead of being
|
||||
// JSON-escaped into one giant line that floods the terminal (#13038).
|
||||
// Non-text blocks keep their identifying metadata plus their base64
|
||||
// payloads so mixed results are not silently truncated.
|
||||
const content = (raw as { content?: unknown }).content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((part) => {
|
||||
if (!isRecord(part)) return "";
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "resource" && isRecord(part.resource)) {
|
||||
if (typeof part.resource.text === "string") {
|
||||
return part.resource.text;
|
||||
}
|
||||
if (typeof part.resource.blob === "string" && part.resource.blob) {
|
||||
const label =
|
||||
typeof part.resource.uri === "string"
|
||||
? `[resource: ${part.resource.uri}]`
|
||||
: "[resource]";
|
||||
return `${label}\n${chunkBase64(part.resource.blob)}`;
|
||||
}
|
||||
if (typeof part.resource.uri === "string") {
|
||||
return `[resource: ${part.resource.uri}]`;
|
||||
}
|
||||
}
|
||||
if (part.type === "resource_link" && typeof part.uri === "string") {
|
||||
return `[resource_link: ${part.uri}]`;
|
||||
}
|
||||
if (
|
||||
(part.type === "image" || part.type === "audio") &&
|
||||
typeof part.mimeType === "string"
|
||||
) {
|
||||
if (typeof part.data === "string" && part.data) {
|
||||
return `[${part.type}: ${part.mimeType}]\n${chunkBase64(part.data)}`;
|
||||
}
|
||||
return `[${part.type}: ${part.mimeType}]`;
|
||||
}
|
||||
return typeof part.type === "string" ? `[${part.type}]` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) return parts.join("\n");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(raw, null, 2);
|
||||
} catch {
|
||||
|
||||
@@ -15,17 +15,14 @@ import {
|
||||
StatusBar,
|
||||
} from "../components/status-bar";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../hooks/use-terminal-background";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import {
|
||||
getInputRuleColor,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
getModeInputForeground,
|
||||
getModeInputPlaceholder,
|
||||
} from "../palette";
|
||||
import { getThemeModeAccent } from "../themes";
|
||||
import type {
|
||||
QueuedPromptItem,
|
||||
RuntimeToolInteraction,
|
||||
@@ -73,9 +70,9 @@ export function ChatView(props: {
|
||||
repoStatus,
|
||||
} = props;
|
||||
const session = useSession();
|
||||
const terminalBg = useTerminalBackground();
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const accent = getModeAccent(session.uiMode, terminalTheme);
|
||||
const theme = useTheme();
|
||||
const terminalBg = theme.background;
|
||||
const accent = getThemeModeAccent(theme, session.uiMode);
|
||||
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
|
||||
const inputRuleColor = getInputRuleColor(terminalBg);
|
||||
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isToggleableInteractiveConfigItem } from "../../tui/interactive-config"
|
||||
export type ConfigAction =
|
||||
| { kind: "open-provider" }
|
||||
| { kind: "open-model" }
|
||||
| { kind: "open-theme" }
|
||||
| { kind: "toggle-item"; item: InteractiveConfigItem }
|
||||
| { kind: "delete-item"; item: InteractiveConfigItem }
|
||||
| {
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
|
||||
import { resolveModelDisplayName } from "../components/status-bar";
|
||||
import { getModeAccent, palette } from "../palette";
|
||||
import { useThemeController } from "../hooks/use-theme";
|
||||
import { palette } from "../palette";
|
||||
import { getDialogAccents, getThemeDefinition } from "../themes";
|
||||
import {
|
||||
type ConfigAction,
|
||||
canDeleteConfigFooterRow,
|
||||
@@ -406,6 +408,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const [togglingItemId, setTogglingItemId] = useState<string | null>(null);
|
||||
const [toggleError, setToggleError] = useState<string | undefined>();
|
||||
const [navPos, setNavPos] = useState(0);
|
||||
const themeController = useThemeController();
|
||||
const dialogAccents = getDialogAccents(themeController.theme);
|
||||
const currentThemeLabel =
|
||||
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
|
||||
|
||||
const displayName = resolveModelDisplayName(config);
|
||||
|
||||
@@ -459,6 +465,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
r.push({ kind: "provider" });
|
||||
r.push({ kind: "model" });
|
||||
r.push({ kind: "toggle", id: "mode", label: "Mode" });
|
||||
r.push({ kind: "toggle", id: "theme", label: "Theme" });
|
||||
r.push({ kind: "toggle", id: "compaction", label: "Compaction" });
|
||||
r.push({
|
||||
kind: "toggle",
|
||||
@@ -601,6 +608,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
setMode(mode === "plan" ? "act" : "plan");
|
||||
props.onToggleMode();
|
||||
break;
|
||||
case "theme":
|
||||
resolve({ kind: "open-theme" });
|
||||
break;
|
||||
case "auto-approve":
|
||||
setAutoApprove(!autoApprove);
|
||||
props.onToggleAutoApprove();
|
||||
@@ -813,7 +823,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
let valueColor: string;
|
||||
if (row.id === "mode") {
|
||||
value = mode === "plan" ? "Plan" : "Act";
|
||||
valueColor = getModeAccent(mode);
|
||||
valueColor =
|
||||
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
|
||||
} else if (row.id === "theme") {
|
||||
value = currentThemeLabel;
|
||||
valueColor = dialogAccents.act;
|
||||
} else if (row.id === "auto-approve") {
|
||||
value = autoApprove ? "● on" : "○ off";
|
||||
valueColor = autoApprove ? palette.success : "gray";
|
||||
|
||||
@@ -13,17 +13,13 @@ import {
|
||||
} from "../components/status-bar";
|
||||
import { TrackedRobot, useMouseTracker } from "../components/tracked-robot";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getInputRuleColor,
|
||||
getModeAccent,
|
||||
getModeInputForeground,
|
||||
getModeInputPlaceholder,
|
||||
} from "../palette";
|
||||
import { getThemeModeAccent } from "../themes";
|
||||
import { HOME_VIEW_MAX_WIDTH, type TuiProps } from "../types";
|
||||
|
||||
export function HomeView(props: {
|
||||
@@ -65,10 +61,10 @@ export function HomeView(props: {
|
||||
visualRow: number;
|
||||
} | null>(null);
|
||||
|
||||
const terminalBg = useTerminalBackground();
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const accent = getModeAccent(session.uiMode, terminalTheme);
|
||||
const theme = useTheme();
|
||||
const terminalBg = theme.background;
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const accent = getThemeModeAccent(theme, session.uiMode);
|
||||
const inputRuleColor = getInputRuleColor(terminalBg);
|
||||
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
|
||||
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
type SearchableItem,
|
||||
useSearchableList,
|
||||
} from "../../components/searchable-list";
|
||||
import { palette } from "../../palette";
|
||||
import { useTheme } from "../../hooks/use-theme";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
@@ -82,6 +82,7 @@ export interface OnboardingControllerProps {
|
||||
|
||||
export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const { onComplete } = props;
|
||||
const theme = useTheme();
|
||||
const providerSettingsManager = useMemo(
|
||||
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
|
||||
[props.providerSettingsManager],
|
||||
@@ -147,9 +148,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
: undefined,
|
||||
searchText: `${p.name} ${p.id}`,
|
||||
rightLabel: p.hasAuth ? "\u25cf" : undefined,
|
||||
rightLabelColor: palette.success,
|
||||
rightLabelColor: theme.accents.success,
|
||||
})),
|
||||
[providers],
|
||||
[providers, theme.accents.success],
|
||||
);
|
||||
|
||||
const providerList = useSearchableList(providerItems);
|
||||
|
||||
@@ -19,11 +19,8 @@ import {
|
||||
TrackedRobot,
|
||||
type useMouseTracker,
|
||||
} from "../../components/tracked-robot";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
|
||||
import { useTheme } from "../../hooks/use-theme";
|
||||
import { getInputRuleColor, getUserMessageBackground } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
@@ -35,8 +32,24 @@ import {
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
function useDefaultFg(): string | undefined {
|
||||
const terminalBg = useTerminalBackground();
|
||||
return getDefaultForeground(terminalBg);
|
||||
return useTheme().defaultForeground;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-derived colors for the onboarding surface. The subtle border/detail
|
||||
* tones used to be fixed dark grays (#333333 / #555555), which disappear on
|
||||
* light or tinted theme backgrounds; they now lift from the theme background.
|
||||
*/
|
||||
function useOnboardingColors() {
|
||||
const theme = useTheme();
|
||||
return {
|
||||
accent: theme.accents.act,
|
||||
success: theme.accents.success,
|
||||
selection: theme.selection,
|
||||
textOnSelection: theme.textOnSelection,
|
||||
subtleBorder: getUserMessageBackground(theme.background),
|
||||
mutedDetail: getInputRuleColor(theme.background),
|
||||
};
|
||||
}
|
||||
|
||||
function getClinePassSubscriptionOptionId(index: number): string {
|
||||
@@ -81,6 +94,7 @@ function OnboardingFrame({
|
||||
}
|
||||
|
||||
export function OnboardingDoneScreen(props: { mouse: MouseTrackerState }) {
|
||||
const colors = useOnboardingColors();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -90,7 +104,7 @@ export function OnboardingDoneScreen(props: { mouse: MouseTrackerState }) {
|
||||
alignItems="center"
|
||||
onMouseMove={props.mouse.onMouseMove}
|
||||
>
|
||||
<text fg={palette.success}>{"\u2714"} You're all set!</text>
|
||||
<text fg={colors.success}>{"\u2714"} You're all set!</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -106,6 +120,7 @@ export function OnboardingOAuthPendingScreen(props: {
|
||||
oauthProvider: string;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
@@ -117,7 +132,7 @@ export function OnboardingOAuthPendingScreen(props: {
|
||||
|
||||
{!props.authError && (
|
||||
<box flexDirection="row" gap={1} justifyContent="center">
|
||||
<spinner name="dots" color={palette.act} />
|
||||
<spinner name="dots" color={colors.accent} />
|
||||
<text fg="gray">{props.authStatus}</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -134,13 +149,13 @@ export function OnboardingOAuthPendingScreen(props: {
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="#333333"
|
||||
borderColor={colors.subtleBorder}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={props.contentWidth}
|
||||
>
|
||||
<text fg="gray">If the browser didn't open:</text>
|
||||
<text fg={palette.act} marginTop={1} selectable>
|
||||
<text fg={colors.accent} marginTop={1} selectable>
|
||||
<a href={props.authUrl}>{props.authUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -165,6 +180,7 @@ export function OnboardingDeviceCodeScreen(props: {
|
||||
mouse: MouseTrackerState;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
@@ -176,7 +192,7 @@ export function OnboardingDeviceCodeScreen(props: {
|
||||
|
||||
{!props.deviceUserCode && !props.deviceError && (
|
||||
<box flexDirection="row" gap={1} justifyContent="center">
|
||||
<spinner name="dots" color={palette.act} />
|
||||
<spinner name="dots" color={colors.accent} />
|
||||
<text fg="gray">{props.deviceStatus}</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -193,7 +209,7 @@ export function OnboardingDeviceCodeScreen(props: {
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={colors.accent}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={props.contentWidth}
|
||||
@@ -207,7 +223,7 @@ export function OnboardingDeviceCodeScreen(props: {
|
||||
<text fg="gray" marginTop={1}>
|
||||
Visit this URL and enter the code above:
|
||||
</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={colors.accent} selectable>
|
||||
<a href={props.deviceVerifyUrl}>{props.deviceVerifyUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -215,7 +231,7 @@ export function OnboardingDeviceCodeScreen(props: {
|
||||
|
||||
{props.deviceUserCode && !props.deviceError && (
|
||||
<box flexDirection="row" gap={1} justifyContent="center">
|
||||
<spinner name="dots" color={palette.act} />
|
||||
<spinner name="dots" color={colors.accent} />
|
||||
<text fg="gray">Waiting for sign-in...</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -276,6 +292,7 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
const visibleFields = FIELD_ORDER.filter(
|
||||
(key) => props.fields[key] !== undefined,
|
||||
);
|
||||
@@ -314,7 +331,7 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={isFocused ? palette.act : "gray"}
|
||||
borderColor={isFocused ? colors.accent : "gray"}
|
||||
paddingX={1}
|
||||
>
|
||||
<input
|
||||
@@ -354,6 +371,7 @@ export function OnboardingCodexCliScreen(props: {
|
||||
status?: CodexCliStatus;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
const installedStatus =
|
||||
props.status?.installed === true ? props.status : undefined;
|
||||
return (
|
||||
@@ -374,7 +392,7 @@ export function OnboardingCodexCliScreen(props: {
|
||||
|
||||
{installedStatus && (
|
||||
<box flexDirection="column" gap={1} alignItems="center">
|
||||
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg="gray">{installedStatus.version}</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -384,7 +402,7 @@ export function OnboardingCodexCliScreen(props: {
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="gray">{props.status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={colors.accent} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
@@ -494,8 +512,8 @@ export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
subscriptionUrl: string;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const planAccent = getModeAccent("plan", terminalTheme);
|
||||
const planAccent = useTheme().accents.plan;
|
||||
const colors = useOnboardingColors();
|
||||
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const isLoading = props.status === "loading";
|
||||
const isSubscribed = props.status === "subscribed";
|
||||
@@ -527,7 +545,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={isSubscribed ? palette.success : planAccent}
|
||||
borderColor={isSubscribed ? colors.success : planAccent}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
height={bodyHeight}
|
||||
@@ -544,7 +562,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
>
|
||||
<box flexDirection="column" width="100%" flexShrink={0}>
|
||||
<text
|
||||
fg={isSubscribed ? palette.success : planAccent}
|
||||
fg={isSubscribed ? colors.success : planAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSubscribed
|
||||
@@ -622,19 +640,19 @@ export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
backgroundColor={isSel ? colors.selection : undefined}
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
fg={isSel ? colors.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : defaultFg}
|
||||
fg={isSel ? colors.textOnSelection : defaultFg}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option.label}
|
||||
@@ -656,7 +674,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
|
||||
<text fg="gray" flexShrink={0}>
|
||||
If the browser button does not work:
|
||||
</text>
|
||||
<text fg={palette.act} selectable flexShrink={0}>
|
||||
<text fg={colors.accent} selectable flexShrink={0}>
|
||||
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -786,6 +804,7 @@ export function OnboardingThinkingLevelScreen(props: {
|
||||
thinkingSelected: number;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
@@ -808,19 +827,16 @@ export function OnboardingThinkingLevelScreen(props: {
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
backgroundColor={isSel ? colors.selection : undefined}
|
||||
height={1}
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={isSel ? colors.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text fg={isSel ? palette.textOnSelection : defaultFg}>
|
||||
<text fg={isSel ? colors.textOnSelection : defaultFg}>
|
||||
{level.label}
|
||||
</text>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"}>
|
||||
<text fg={isSel ? colors.textOnSelection : "gray"}>
|
||||
{level.desc}
|
||||
</text>
|
||||
</box>
|
||||
@@ -842,6 +858,7 @@ export function OnboardingMainMenuScreen(props: {
|
||||
mouse: MouseTrackerState;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -884,20 +901,25 @@ export function OnboardingMainMenuScreen(props: {
|
||||
flexDirection="row"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={isSel ? palette.act : "#333333"}
|
||||
borderColor={isSel ? colors.accent : colors.subtleBorder}
|
||||
paddingX={1}
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<text fg={isSel ? palette.act : "#555555"} flexShrink={0}>
|
||||
<text
|
||||
fg={isSel ? colors.accent : colors.mutedDetail}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option.icon}
|
||||
</text>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<text fg={isSel ? defaultFg : "gray"}>{option.label}</text>
|
||||
<text fg={isSel ? "gray" : "#555555"}>{option.detail}</text>
|
||||
<text fg={isSel ? "gray" : colors.mutedDetail}>
|
||||
{option.detail}
|
||||
</text>
|
||||
</box>
|
||||
{isSel && (
|
||||
<text fg={palette.act} flexShrink={0}>
|
||||
<text fg={colors.accent} flexShrink={0}>
|
||||
{"\u2192"}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isCliHookPayload,
|
||||
normalizeAutoApproveArgs,
|
||||
parseArgs,
|
||||
truncate,
|
||||
} from "./helpers";
|
||||
|
||||
type EnvSnapshot = {
|
||||
@@ -374,6 +375,73 @@ describe("format helpers", () => {
|
||||
).toBe("first (+2 more)");
|
||||
expect(formatToolOutput(null)).toBe("");
|
||||
});
|
||||
|
||||
// Regression tests for https://github.com/cline/cline/issues/13036:
|
||||
// malformed tool inputs crossing the model/tool boundary must never
|
||||
// throw from display-only formatters.
|
||||
it("does not crash on run_commands with a null command", () => {
|
||||
expect(formatToolInput("run_commands", { command: null })).toBe("");
|
||||
});
|
||||
|
||||
it("does not crash on run_commands with a non-string command", () => {
|
||||
expect(formatToolInput("run_commands", { command: { nested: true } })).toBe(
|
||||
'{"nested":true}',
|
||||
);
|
||||
expect(formatToolInput("run_commands", { commands: { command: 42 } })).toBe(
|
||||
"42",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid empty-string args in structured command summaries", () => {
|
||||
expect(
|
||||
formatToolInput("run_commands", {
|
||||
commands: [{ command: "grep", args: ["", "pattern", "file.txt"] }],
|
||||
}),
|
||||
).toBe("grep pattern file.txt");
|
||||
expect(
|
||||
formatToolInput("run_commands", {
|
||||
commands: [{ command: "git", args: [null, "status", undefined] }],
|
||||
}),
|
||||
).toBe("git status");
|
||||
});
|
||||
|
||||
it("skips null entries in run_commands command arrays", () => {
|
||||
expect(
|
||||
formatToolInput("run_commands", { commands: [null, "echo hi"] }),
|
||||
).toBe("echo hi");
|
||||
expect(formatToolInput("run_commands", [undefined, "echo hi"])).toBe(
|
||||
"echo hi",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not crash on fetch_web_content with malformed requests", () => {
|
||||
expect(
|
||||
formatToolInput("fetch_web_content", {
|
||||
requests: [null, { url: "https://example.com" }, { url: 42 }, "raw"],
|
||||
}),
|
||||
).toBe("https://example.com, 42");
|
||||
});
|
||||
|
||||
it("falls back to an empty summary for unserializable inputs", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
expect(formatToolInput("unknown_tool", circular)).toBe("");
|
||||
expect(formatToolOutput(circular)).toBe("");
|
||||
expect(
|
||||
formatToolInput("unknown_tool", {
|
||||
toJSON() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("truncates non-string values without throwing", () => {
|
||||
expect(truncate(null, 10)).toBe("");
|
||||
expect(truncate(undefined, 10)).toBe("");
|
||||
expect(truncate(42, 10)).toBe("42");
|
||||
expect(truncate({ nested: true }, 60)).toBe('{"nested":true}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook payload validation and audit logging", () => {
|
||||
|
||||
@@ -53,8 +53,38 @@ export function resolveWorkspaceRoot(cwd: string): string {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function truncate(str: string, maxLen: number): string {
|
||||
const oneLine = str.replace(/\n/g, " ").trim();
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an untrusted runtime value into a display string without ever
|
||||
* throwing. Tool inputs/outputs cross the model/tool boundary, so they may
|
||||
* not match their TypeScript annotations (e.g. `{ command: null }`).
|
||||
*/
|
||||
export function toDisplayString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return safeJsonStringify(value);
|
||||
}
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(value: unknown, maxLen: number): string {
|
||||
const oneLine = toDisplayString(value).replace(/\n/g, " ").trim();
|
||||
if (oneLine.length <= maxLen) {
|
||||
return oneLine;
|
||||
}
|
||||
@@ -66,14 +96,21 @@ export function formatStructuredCommand(cmd: unknown): string {
|
||||
return cmd;
|
||||
}
|
||||
if (cmd && typeof cmd === "object" && "command" in cmd) {
|
||||
const structured = cmd as { command: string; args?: unknown };
|
||||
const args = Array.isArray(structured.args) ? structured.args : [];
|
||||
const structured = cmd as { command?: unknown; args?: unknown };
|
||||
const command = toDisplayString(structured.command);
|
||||
// Drop only nullish entries: they carry no display value, while an
|
||||
// empty string is a valid argv entry that must stay in the summary.
|
||||
const args = Array.isArray(structured.args)
|
||||
? structured.args
|
||||
.filter((arg) => arg !== null && arg !== undefined)
|
||||
.map(toDisplayString)
|
||||
: [];
|
||||
if (args.length === 0) {
|
||||
return structured.command;
|
||||
return command;
|
||||
}
|
||||
return `${structured.command} ${args.join(" ")}`;
|
||||
return `${command} ${args.join(" ")}`;
|
||||
}
|
||||
return String(cmd);
|
||||
return toDisplayString(cmd);
|
||||
}
|
||||
|
||||
function summarizeRunCommandsInput(input: unknown): string {
|
||||
@@ -82,14 +119,17 @@ function summarizeRunCommandsInput(input: unknown): string {
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(formatStructuredCommand).join("; ");
|
||||
return input.map(formatStructuredCommand).filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (obj.commands !== undefined) {
|
||||
if (Array.isArray(obj.commands)) {
|
||||
return obj.commands.map(formatStructuredCommand).join("; ");
|
||||
return obj.commands
|
||||
.map(formatStructuredCommand)
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
return formatStructuredCommand(obj.commands);
|
||||
}
|
||||
@@ -157,7 +197,11 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
if (Array.isArray(obj.requests)) {
|
||||
return truncate(
|
||||
obj.requests
|
||||
.map((r) => r.url)
|
||||
.map((r) =>
|
||||
r && typeof r === "object" && "url" in r
|
||||
? toDisplayString((r as { url?: unknown }).url)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
120,
|
||||
@@ -286,7 +330,7 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
return "list";
|
||||
}
|
||||
|
||||
return truncate(JSON.stringify(input), 60);
|
||||
return truncate(input, 60);
|
||||
}
|
||||
|
||||
export function formatToolOutput(output: unknown): string {
|
||||
@@ -330,10 +374,10 @@ export function formatToolOutput(output: unknown): string {
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ") || "Successfully read image"
|
||||
: String(result ?? "");
|
||||
: toDisplayString(result);
|
||||
return truncate(resultStr, 80);
|
||||
}
|
||||
return truncate(JSON.stringify(item), 80);
|
||||
return truncate(item, 80);
|
||||
})
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
@@ -346,7 +390,7 @@ export function formatToolOutput(output: unknown): string {
|
||||
return `${results[0]} (+${results.length - 1} more)`;
|
||||
}
|
||||
|
||||
return truncate(JSON.stringify(output), 100);
|
||||
return truncate(output, 100);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSameRepoStatus } from "./repo-status";
|
||||
|
||||
describe("isSameRepoStatus", () => {
|
||||
it("treats identical statuses as equal", () => {
|
||||
expect(
|
||||
isSameRepoStatus(
|
||||
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 3 } },
|
||||
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 3 } },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameRepoStatus(
|
||||
{ branch: null, diffStats: null },
|
||||
{ branch: null, diffStats: null },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects branch and diff changes", () => {
|
||||
expect(
|
||||
isSameRepoStatus(
|
||||
{ branch: "main", diffStats: null },
|
||||
{ branch: "feature", diffStats: null },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSameRepoStatus(
|
||||
{ branch: "main", diffStats: null },
|
||||
{ branch: "main", diffStats: { files: 1, additions: 0, deletions: 0 } },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSameRepoStatus(
|
||||
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 3 } },
|
||||
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 4 } },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,18 @@ export interface RepoStatus {
|
||||
diffStats: GitDiffStats | null;
|
||||
}
|
||||
|
||||
export function isSameRepoStatus(a: RepoStatus, b: RepoStatus): boolean {
|
||||
if (a.branch !== b.branch) return false;
|
||||
if (a.diffStats === null || b.diffStats === null) {
|
||||
return a.diffStats === b.diffStats;
|
||||
}
|
||||
return (
|
||||
a.diffStats.files === b.diffStats.files &&
|
||||
a.diffStats.additions === b.diffStats.additions &&
|
||||
a.diffStats.deletions === b.diffStats.deletions
|
||||
);
|
||||
}
|
||||
|
||||
export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
|
||||
const [branchResult, diffResult] = await Promise.allSettled([
|
||||
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type McpServerEntry,
|
||||
type McpTransport,
|
||||
removeServer,
|
||||
setServerOAuthClient,
|
||||
toggleServer,
|
||||
updateServer,
|
||||
} from "./settings";
|
||||
@@ -49,6 +50,7 @@ type RemoteAuthMode = "none" | "headers" | "oauth";
|
||||
interface UrlServerConfig {
|
||||
transport: McpTransport;
|
||||
authMode: RemoteAuthMode;
|
||||
oauthClient?: { clientId: string; clientSecret?: string };
|
||||
}
|
||||
|
||||
export interface McpAddDefaults {
|
||||
@@ -196,7 +198,29 @@ async function collectUrlTransport(
|
||||
});
|
||||
if (isCancel(authMode)) return null;
|
||||
|
||||
if (authMode === "oauth" || authMode === "none") {
|
||||
if (authMode === "oauth") {
|
||||
const clientId = await p.text({
|
||||
message: "OAuth client ID (leave empty for dynamic registration)",
|
||||
});
|
||||
if (isCancel(clientId)) return null;
|
||||
const normalizedClientId = (clientId as string).trim();
|
||||
let clientSecret: string | undefined;
|
||||
if (normalizedClientId) {
|
||||
const secret = await p.password({
|
||||
message: "OAuth client secret (leave empty for public clients)",
|
||||
});
|
||||
if (isCancel(secret)) return null;
|
||||
clientSecret = (secret as string).trim() || undefined;
|
||||
}
|
||||
return {
|
||||
transport: { type, url: (url as string).trim() },
|
||||
authMode,
|
||||
oauthClient: normalizedClientId
|
||||
? { clientId: normalizedClientId, clientSecret }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (authMode === "none") {
|
||||
return {
|
||||
transport: { type, url: (url as string).trim() },
|
||||
authMode,
|
||||
@@ -270,6 +294,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
let oauthClient: UrlServerConfig["oauthClient"];
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport(defaults?.command);
|
||||
} else {
|
||||
@@ -279,6 +304,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
);
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
oauthClient = config?.oauthClient;
|
||||
}
|
||||
if (!transport) return;
|
||||
|
||||
@@ -286,6 +312,8 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
addServer(serverName, transport);
|
||||
if (authMode !== "oauth") {
|
||||
clearServerOAuth(serverName);
|
||||
} else {
|
||||
setServerOAuthClient(serverName, oauthClient);
|
||||
}
|
||||
p.log.success(`Added "${serverName}" to ${getSettingsPath()}`);
|
||||
if (authMode === "oauth") {
|
||||
@@ -374,18 +402,22 @@ async function actionEdit(): Promise<void> {
|
||||
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
let oauthClient: UrlServerConfig["oauthClient"];
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport();
|
||||
} else {
|
||||
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
oauthClient = config?.oauthClient;
|
||||
}
|
||||
if (!transport) return;
|
||||
|
||||
updateServer(name, transport);
|
||||
if (type === "stdio" || authMode !== "oauth") {
|
||||
clearServerOAuth(name);
|
||||
} else {
|
||||
setServerOAuthClient(name, oauthClient);
|
||||
}
|
||||
p.log.success(`Updated "${name}"`);
|
||||
if (authMode === "oauth") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user