mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 452733c3fd | |||
| 7fadcfaa3f | |||
| 9989225b69 | |||
| 8a73f63189 | |||
| 31e8c85f0a | |||
| 5b9916866d | |||
| f001e735f8 | |||
| 32893ee343 | |||
| 0d4e47e5c3 | |||
| c1a43482e7 | |||
| ea65383e16 | |||
| b97d1487a7 | |||
| 091cf945e4 | |||
| df4f551ba7 | |||
| 88da4ddf89 | |||
| 93eb607e6d | |||
| 810b5b78f5 | |||
| 38ea422f6a | |||
| a7a35c0138 | |||
| 01547ba1f4 | |||
| 7f1632f09f | |||
| 1b0ab3d01b | |||
| dce0902596 | |||
| 9e7a30bd34 | |||
| 2b1b1d1cf2 | |||
| fcf3792f63 | |||
| 0e833ade82 | |||
| 4455db5198 | |||
| 03ab2968a6 | |||
| a0d52d4d59 | |||
| 75fbeb4aad | |||
| 70db6bde34 | |||
| 02c2601e0e | |||
| 94692b5091 | |||
| a2794c680f | |||
| 6d3f8e1d5d | |||
| 3e5847890b | |||
| 0eab54ab12 | |||
| 7fa0a4924b | |||
| 8680218e0e | |||
| 8787ab35b9 | |||
| 6ed3944f04 | |||
| 1dd8e763d1 | |||
| 28e6297769 | |||
| 5a2a5d1c0a | |||
| 113039a259 | |||
| 4023c18257 | |||
| 7c95b53892 | |||
| 9fd2b99be4 | |||
| 7c782abaf4 | |||
| 3a14a88f4f | |||
| 28d60a83a4 | |||
| ae6468b161 | |||
| af93e31862 | |||
| ca154eb8f5 | |||
| 1d8497c6bf | |||
| 5871fd02b1 | |||
| 2d81c310d2 | |||
| 0c691f72d2 | |||
| a8409137b3 | |||
| 34a21b8e26 | |||
| eb9f53edf4 | |||
| c60f18d907 | |||
| 36c68a6ab9 | |||
| 80dfce0f60 | |||
| 955ae2f62f | |||
| 8cb0c6d236 | |||
| bb05b2f7b0 | |||
| 1a911f4232 | |||
| 1cfd560921 | |||
| 203ff8f549 | |||
| e920f1de02 | |||
| b266475d0a | |||
| 402361c482 | |||
| e884699d24 | |||
| f59d950ac6 | |||
| d2e4f1c7b9 | |||
| 49975fd0a3 | |||
| c0020e10b1 | |||
| 8dc5e15ee0 | |||
| 276cb4c3a4 | |||
| 8169abbc74 | |||
| a47ad46824 | |||
| 7896e6d896 | |||
| 166ec38d26 | |||
| e44ea9d772 | |||
| fcca1d4fe3 | |||
| 34f0795217 | |||
| 0648ed42b2 | |||
| f32dde2c16 | |||
| 605c497eaf | |||
| a8322eec44 | |||
| 974a7a748a | |||
| fb2d092301 | |||
| 45b3eb4833 | |||
| a5048189e5 | |||
| 897e842eb4 | |||
| 0389d4de07 | |||
| d99eec15d8 | |||
| 98ed009e69 | |||
| 8fb7b94297 | |||
| d8397c71b2 | |||
| 3899469d76 | |||
| e85332319e | |||
| 1a29d428ae | |||
| 92cf03e42c | |||
| 3ea393a5e9 | |||
| 9829e7d49e | |||
| 56de96e5ff | |||
| ecde79cf08 | |||
| ff36cbcb87 | |||
| 5a75f08118 | |||
| 120754c2fe | |||
| 5d048d09f8 | |||
| 36580ce086 | |||
| c584bf4185 | |||
| 8133babf41 | |||
| 741f524da7 | |||
| d3918dd7df | |||
| 024bb65443 | |||
| 58ebbdbf80 | |||
| cee74d2f3a | |||
| a6f3b9f856 | |||
| 0e524ffc3a | |||
| 95ca14fa2a | |||
| a6c57a4ce5 | |||
| 9470cf19cd | |||
| acf34860bb | |||
| 49cad88aca | |||
| 3c65cbc7f2 | |||
| 12603d4be1 | |||
| b514f18e4f | |||
| ce85d7d414 | |||
| 739d75afe3 | |||
| fc1be2baac | |||
| 5dcaa8c8cc | |||
| 79cf77db0a | |||
| 4b61799df5 | |||
| 806708e802 | |||
| 642ea849e3 | |||
| dff8193de5 | |||
| a05c1c5e53 | |||
| 9a11976d27 | |||
| b54647ab17 | |||
| 6e253dfa9a | |||
| 7236d02ebb | |||
| 84fef6fe1f | |||
| 7bdbf0a9a7 | |||
| bab336f172 | |||
| 967342999f | |||
| d19a8779e7 | |||
| 5c04fa3aa3 | |||
| 7c31c1d02a | |||
| 740f99400b | |||
| 195294f389 | |||
| 2cc070eee2 | |||
| 627838243e | |||
| f8a1f75664 | |||
| 54aeba1fee | |||
| a03642ba4a | |||
| b65435fc55 | |||
| 88694d39fa | |||
| 6c53daa88e | |||
| 1f3c00c613 | |||
| 4d455ea015 | |||
| 3daf24662e | |||
| 942fcf5762 | |||
| 70a99047ed | |||
| 844038084c | |||
| 0c6f77ea46 | |||
| 9b70f94174 | |||
| 0a4f939ecb |
+21
-6
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: create-pull-request
|
||||
description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, and PR creation using the gh CLI tool.
|
||||
description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, PR template usage, and PR creation using the gh CLI tool.
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
@@ -147,14 +147,29 @@ When filling out the template:
|
||||
|
||||
### Create PR with gh CLI
|
||||
|
||||
**Use a temporary file for the PR body** to avoid shell escaping issues, newline problems, and other command-line flakiness:
|
||||
|
||||
1. Write the PR body to a temporary file:
|
||||
```
|
||||
/tmp/pr-body.md
|
||||
```
|
||||
|
||||
2. Create the PR using the file:
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body-file /tmp/pr-body.md --base main
|
||||
```
|
||||
|
||||
3. Clean up the temporary file:
|
||||
```bash
|
||||
rm /tmp/pr-body.md
|
||||
```
|
||||
|
||||
For draft PRs:
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main
|
||||
gh pr create --title "PR_TITLE" --body-file /tmp/pr-body.md --base main --draft
|
||||
```
|
||||
|
||||
Alternatively, create as draft if the user wants review before marking ready:
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main --draft
|
||||
```
|
||||
**Why use a file?** Passing complex markdown with newlines, special characters, and checkboxes directly via `--body` is error-prone. The `--body-file` flag handles all content reliably.
|
||||
|
||||
## Post-Creation
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Replace the LiteLLM model list with a selector
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix(cli): prevent hang when spawned without TTY
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add Claude Opus 4.6 model support
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add /q command to quit CLI
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Supports rendering markdown table in chat view.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Fix CLI crashing in CI environments and with stdin redirection (e.g., `cline "prompt" < /dev/null`). Now checks both stdin and stdout TTY status before using Ink, and only errors on empty stdin when no prompt is provided.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix: use vscode.env.openExternal for auth in remote environments
|
||||
|
||||
Fixes OAuth authentication in VS Code Server and remote environments by routing browser URL opening through VS Code's native openExternal API instead of the npm 'open' package.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Updating script documentation and removing unnecessary continue on error
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix a streaming crash when a chunk has usage data but no `delta` by guarding reasoning field checks in provider handlers. Add regression tests for OpenRouter, Cline, Vercel AI Gateway, and Fireworks handlers to cover usage-only chunks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Fix Bedrock model id
|
||||
@@ -0,0 +1,4 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add automatic retries (up to 3 attempts) for smoke test CI jobs to reduce flaky failures
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix acp auth check so acp mode can be used with more providers
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Add Generate API Key on Hicap Provider selection
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Update SambaNova Provider models list and add temperature for models
|
||||
@@ -1,26 +0,0 @@
|
||||
changesDir: .changes
|
||||
unreleasedDir: unreleased
|
||||
headerPath: header.tpl.md
|
||||
changelogPath: CHANGELOG.md
|
||||
versionExt: md
|
||||
versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
|
||||
kindFormat: "### {{.Kind}}"
|
||||
changeFormat: "* {{.Body}}"
|
||||
kinds:
|
||||
- label: Added
|
||||
auto: minor
|
||||
- label: Changed
|
||||
auto: major
|
||||
- label: Deprecated
|
||||
auto: minor
|
||||
- label: Removed
|
||||
auto: major
|
||||
- label: Fixed
|
||||
auto: patch
|
||||
- label: Security
|
||||
auto: patch
|
||||
newlines:
|
||||
afterChangelogHeader: 1
|
||||
beforeChangelogVersion: 1
|
||||
endOfVersion: 1
|
||||
envPrefix: CHANGIE_
|
||||
+12
-1
@@ -14,7 +14,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
|
||||
## Miscellaneous
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -147,6 +147,17 @@ Required steps:
|
||||
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
|
||||
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
|
||||
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
|
||||
- `src/core/controller/state/updateSettingsCli.ts` for CLI/ACP settings updates
|
||||
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
|
||||
|
||||
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
|
||||
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
|
||||
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Storage Architecture
|
||||
|
||||
Global settings, secrets and workspace state are stored in **file-backed JSON stores** under `~/.cline/data/`. This is the shared storage layer used by VSCode, CLI, and JetBrains.
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
### `StorageContext` (src/shared/storage/storage-context.ts)
|
||||
The entry point. Created via `createStorageContext()` and passed to `StateManager.initialize()`. Contains three `ClineFileStorage` instances:
|
||||
- `globalState` → `~/.cline/data/globalState.json`
|
||||
- `secrets` → `~/.cline/data/secrets.json` (mode 0o600)
|
||||
- `workspaceState` → `~/.cline/data/workspaces/<hash>/workspaceState.json`
|
||||
|
||||
### `ClineFileStorage` (src/shared/storage/ClineFileStorage.ts)
|
||||
Synchronous JSON key-value store backed by a single file. Supports `get()`, `set()`, `setBatch()`, `delete()`. Writes are atomic (write-then-rename).
|
||||
|
||||
### `StateManager` (src/core/storage/StateManager.ts)
|
||||
In-memory cache on top of `StorageContext`. All runtime reads hit the cache; writes update cache immediately and debounce-flush to disk.
|
||||
|
||||
## ⚠️ Do NOT Use VSCode's ExtensionContext for Storage
|
||||
|
||||
**Do not** read from or write to `context.globalState`, `context.workspaceState`, or `context.secrets` for persistent data. These are VSCode-specific and not available on CLI or JetBrains.
|
||||
|
||||
Instead, use:
|
||||
```typescript
|
||||
// Reading state
|
||||
StateManager.get().getGlobalStateKey("myKey")
|
||||
StateManager.get().getSecretKey("mySecretKey")
|
||||
StateManager.get().getWorkspaceStateKey("myWsKey")
|
||||
|
||||
// Writing state
|
||||
StateManager.get().setGlobalState("myKey", value)
|
||||
StateManager.get().setSecret("mySecretKey", value)
|
||||
StateManager.get().setWorkspaceState("myWsKey", value)
|
||||
```
|
||||
|
||||
Remember that your data may be read by a different client than the one that wrote it. For example, a value written by Cline in JetBrains may be read by Cline CLI.
|
||||
|
||||
## VSCode Migration (src/hosts/vscode/vscode-to-file-migration.ts)
|
||||
|
||||
On VSCode startup, a migration copies data from VSCode's `ExtensionContext` storage into the file-backed stores. This runs in `src/common.ts` before `StateManager.initialize()`.
|
||||
|
||||
- **Sentinel**: `__vscodeMigrationVersion` key in global state and workspace state — prevents re-migration.
|
||||
- **Merge strategy**: File store wins. Existing values are never overwritten.
|
||||
- **Safe downgrade**: VSCode storage is NOT cleared, so older extension versions still work.
|
||||
|
||||
## Adding New Storage Keys
|
||||
|
||||
1. Add to `src/shared/storage/state-keys.ts` (see existing patterns)
|
||||
2. Read/write via `StateManager` (NOT via `context.globalState`)
|
||||
3. If adding a secret, add to `SecretKeys` array in `state-keys.ts`
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
data/
|
||||
globalState.json # Global settings & state
|
||||
secrets.json # API keys (mode 0o600)
|
||||
tasks/
|
||||
taskHistory.json # Task history (separate file)
|
||||
workspaces/
|
||||
<hash>/
|
||||
workspaceState.json # Per-workspace toggles
|
||||
```
|
||||
@@ -19,7 +19,7 @@ Review and address all comments on the current branch's PR.
|
||||
- Inline comments: `gh api repos/{owner}/{repo}/pulls/{pr_number}/comments`
|
||||
- General comments: `gh pr view {pr_number} --json comments,reviews`
|
||||
|
||||
4. Present a summary of all comments with your recommendation for each (apply, skip, or respond). Ignore bot noise (changeset-bot, CI status, etc.).
|
||||
4. Present a summary of all comments with your recommendation for each (apply, skip, or respond). Ignore bot noise (release automation, CI status, etc.).
|
||||
|
||||
5. **Wait for my approval** before proceeding.
|
||||
|
||||
|
||||
@@ -1,549 +0,0 @@
|
||||
The goal of this workflow is to take a changeset for a release of Cline, an autonomous coding agent extension that plugs right into your IDE, and write the updated announcement component, and the updated changelog.
|
||||
|
||||
|
||||
For reference, here are some examples of how we converted previous changesets to announcement components / changelogs.
|
||||
|
||||
|
||||
- 3.14
|
||||
<changeset>
|
||||
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
|
||||
|
||||
Releases
|
||||
claude-dev@3.14.0
|
||||
Minor Changes
|
||||
77c9863: create clinerules folder if its currently a file and creating new rule
|
||||
0ffb7dd: disabling shift hint for now & improving tooltip behavior
|
||||
79b76fd: Add support for custom model ID in AWS Bedrock provider, enabling use of Application Inference Profile.
|
||||
eb6e481: Full support for LaTeX rendering
|
||||
df37f29: Add support for custom API request timeout. Previously, timeouts were hardcoded to 30 seconds for providers like Ollama or 15 seconds for OpenRouter and Cline. Now users can set a custom timeout value in milliseconds through the settings interface.
|
||||
e4d26be: allow cursorrules and windsurfrules
|
||||
c5de50f: Fix Handle @withRetry() SyntaxError when running extension locally issue
|
||||
61d2f42: enabled pricing calculation for gemini and vertex + more robust caching & cache tracking for gemini & vertex
|
||||
aed152b: add truncation notice when truncating manually
|
||||
2fe2405: Migrate Cline Tools Section to new docs
|
||||
19cc8bc: Add a timeout setting for the terminal connection, allowing users to adjust this if they are having timeout issues
|
||||
03d4410: Added copy button to code blocks.
|
||||
c78fe23: addressed race condition in terminal command usage
|
||||
91e222f: add checkpoints after more messages
|
||||
14230e7: add newrule slash command
|
||||
1c7d33a: Add remote config with posthog allowing for disabling new features until they're reading, making for a better developer experience.
|
||||
4196c14: add cache ui for open router and cline provider
|
||||
d97424f: showing expanded task by default
|
||||
5294e78: Refactor to not pass a message for showing the MCP View from the servers modal
|
||||
70cc437: Fix Windows path issue: Correct handling of import.meta.url to avoid leading slash in pathname
|
||||
4b697d8: Migrate the addRemoteServer to protobus
|
||||
Patch Changes
|
||||
c63d9a1: updated drag and drop text to say "drop" instead of "drag"
|
||||
459adf0: Add markdown copy to chat
|
||||
74ec823: Minor UX improvement to drag and drop ux
|
||||
b0961f4: Remove linear pull request action
|
||||
e9ce384: searchCommits protobus migration
|
||||
5802b68: createRuleFile protobus migration
|
||||
df7f9fc: Add dependsOn to more blocks in the tasks.json
|
||||
41ae732: Fix for git commit mentions in repos with no git commits
|
||||
7e78445: Adding args to allow Cursor to open workspaces (for checkpoint testing/development)
|
||||
bdfda6f: feat(bedrock): Introduce Amazon Nova Premier
|
||||
65243ad: Introduce UI library for future UI development
|
||||
4565e06: checkIsImageURL migrated to protobus
|
||||
5a8e9d8: protobus migration for openImage
|
||||
deeda6e: Lowering Gemini cache TTL time
|
||||
db0b022: Adding UI to show openrouter balance next to provider
|
||||
4650ffa: deleteRuleFile protobus migration
|
||||
d4bd755: fix cost calculation
|
||||
</changeset>
|
||||
|
||||
<changelog>
|
||||
## [3.14.0]
|
||||
|
||||
- Add UI to show openrouter balance next to provider
|
||||
- Add support for custom model ID in AWS Bedrock provider, enabling use of Application Inference Profile (Thanks @clicube!)
|
||||
- Add more robust caching & cache tracking for gemini & vertex providers
|
||||
- Add support for LaTeX rendering
|
||||
- Add support for custom API request timeout. Timeouts were 15-30s, but can now be configured via settings for OpenRouter/Cline & Ollama (Thanks @WingsDrafterwork!)
|
||||
- Add truncation notice when truncating manually
|
||||
- Add a timeout setting for the terminal connection, allowing users to set a time to wait for terminal startup
|
||||
- Add copy button to code blocks
|
||||
- Add copy button to markdown blocks (Thanks @weshoke!)
|
||||
- Add checkpoints to more messages
|
||||
- Add slash command to create a new rules file (/newrule)
|
||||
- Add cache ui for open router and cline provider
|
||||
- Add Amazon Nova Premier model to Bedrock (Thanks @watany!)
|
||||
- Add support for cursorrules and windsurfrules
|
||||
- Add support for batch history deletion (Thanks @danix800!)
|
||||
- Improve Drag & Drop experience
|
||||
- Create clinerules folder creating new rule if it's needed
|
||||
- Enable pricing calculation for gemini and vertex providers
|
||||
- Refactor message handling to not show the MCP View of the server modal
|
||||
- Migrate the addRemoteServer to protobus (Thanks @DaveFres!)
|
||||
- Update task header to be expanded by default
|
||||
- Update Gemini cache TTL time to 15 minutes
|
||||
- Fix race condition in terminal command usage
|
||||
- Fix to correctly handle `import.meta.url`, avoiding leading slash in pathname for Windows (Thanks @DaveFres!)
|
||||
- Fix @withRetry() decoration syntax error when running extension locally (Thanks @DaveFres!)
|
||||
- Fix for git commit mentions in repos with no git commits
|
||||
- Fix cost calculation (Thanks @BarreiroT!)
|
||||
</changelog>
|
||||
|
||||
|
||||
<announcement-component>
|
||||
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={h3TitleStyle}>
|
||||
🎉{" "}New in v{minorVersion}
|
||||
</h3>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Gemini prompt caching:</b> Gemini and Vertex providers now support prompt caching and price tracking for
|
||||
Gemini models.
|
||||
</li>
|
||||
<li>
|
||||
<b>Copy Buttons:</b> Buttons were added to Markdown and Code blocks that allow you to copy their contents
|
||||
easily.
|
||||
</li>
|
||||
<li>
|
||||
<b>/newrule command:</b> New slash command to have cline write your .clinerules for you based on your
|
||||
workflow.
|
||||
</li>
|
||||
<li>
|
||||
<b>Drag and drop improvements:</b> Don't forget to hold shift while dragging files!
|
||||
</li>
|
||||
<li>Added more checkpoints across the task, allowing you to restore from more than just file changes.</li>
|
||||
<li>Added support for rendering LaTeX in message responses. (Try asking Cline to show the quadratic formula)</li>
|
||||
</ul>
|
||||
<Accordion isCompact className="pl-0">
|
||||
<AccordionItem
|
||||
key="1"
|
||||
aria-label="Previous Updates"
|
||||
title="Previous Updates:"
|
||||
classNames={{
|
||||
trigger: "bg-transparent border-0 pl-0 pb-0 w-fit",
|
||||
title: "font-bold text-(--vscode-foreground)",
|
||||
indicator:
|
||||
"text-(--vscode-foreground) mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
|
||||
}}>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between
|
||||
projects.
|
||||
</li>
|
||||
<li>
|
||||
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files
|
||||
to plug and play specific rules for the task
|
||||
</li>
|
||||
<li>
|
||||
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a
|
||||
new task (more coming soon!)
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally
|
||||
restore your project when the message was sent!
|
||||
</li>
|
||||
</ul>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/*
|
||||
// Leave this here for an example of how to structure the announcement
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
so I recommend trying them out.
|
||||
<br />
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(vscodeUriScheme)}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "apiConfiguration",
|
||||
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Switch to OpenRouter
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
|
||||
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
|
||||
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
|
||||
</li>
|
||||
<li>
|
||||
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
|
||||
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
</li>
|
||||
<li>
|
||||
When Cline runs commands, you can now type directly in the terminal (+ support for Python
|
||||
environments)
|
||||
</li>
|
||||
</ul>*/}
|
||||
<div style={hrStyle} />
|
||||
<p style={linkContainerStyle}>
|
||||
Join us on{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://x.com/cline">
|
||||
X,
|
||||
</VSCodeLink>{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
|
||||
discord,
|
||||
</VSCodeLink>{" "}
|
||||
or{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
|
||||
r/cline
|
||||
</VSCodeLink>
|
||||
for more updates!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</announcement-component>
|
||||
|
||||
- 3.13
|
||||
|
||||
<changeset>
|
||||
Minor Changes
|
||||
2964388: Added copy button to MermaidBlock component
|
||||
75143a7: Add the ability to fetch from global cline rules files
|
||||
Patch Changes
|
||||
a0252e7: convert inline style to tailwind css of file SettingsView.tsx
|
||||
ab59bd9: Add stream options back to xai provider
|
||||
7276f50: Icons to indicate an action is occuring outside of the users workspace
|
||||
0b19ba6: update to NEW model
|
||||
</changeset>
|
||||
|
||||
<changelog>
|
||||
## [3.13.0]
|
||||
|
||||
- Add Cline rules popover under the chat field, allowing you to easily add, enable & disable workspace level or global rule files
|
||||
- Add new slash command menu letting you type “/“ to do quick actions like creating new tasks
|
||||
- Add ability to edit past messages, with options to restore your workspace back to that point
|
||||
- Allow sending a message when selecting an option provided by the question or plan tool
|
||||
- Add command to jump to Cline's chat input
|
||||
- Add support for OpenAI o3 & 4o-mini (Thanks @PeterDaveHello and @arafatkatze!)
|
||||
- Add baseURL option for Google Gemini provider (Thanks @owengo and @olivierhub!)
|
||||
- Add support for Azure's DeepSeek model. (Thanks @yt3trees!)
|
||||
- Add ability for models that support it to receive image responses from MCP servers (Thanks @rikaaa0928!)
|
||||
- Improve search and replace diff editing by making it more flexible with models that fail to follow structured output instructions. (Thanks @chi-cat!)
|
||||
- Add detection of Ctrl+C termination in terminal, improving output reading issues
|
||||
- Fix issue where some commands with large output would cause UI to freeze
|
||||
- Fix token usage tracking issues with vertex provider (Thanks @mzsima!)
|
||||
- Fix issue with xAI reasoning content not being parsed (Thanks @mrubens!)
|
||||
</changelog>
|
||||
|
||||
<announcement-component>
|
||||
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={h3TitleStyle}>
|
||||
🎉{" "}New in v{minorVersion}
|
||||
</h3>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between projects.
|
||||
</li>
|
||||
<li>
|
||||
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files to plug
|
||||
and play specific rules for the task
|
||||
</li>
|
||||
<li>
|
||||
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a new task
|
||||
(more coming soon!)
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally restore
|
||||
your project when the message was sent!
|
||||
</li>
|
||||
</ul>
|
||||
<h4 style={{ margin: "5px 0 5px" }}>Previous Updates:</h4>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Model Favorites:</b> You can now mark your favorite models when using Cline & OpenRouter providers for
|
||||
quick access!
|
||||
</li>
|
||||
<li>
|
||||
<b>Faster Diff Editing:</b> Improved animation performance for large files, plus a new indicator in chat
|
||||
showing the number of edits Cline makes.
|
||||
</li>
|
||||
<li>
|
||||
<b>New Auto-Approve Options:</b> Turn off Cline's ability to read and edit files outside your workspace.
|
||||
</li>
|
||||
</ul>
|
||||
{/*
|
||||
// Leave this here for an example of how to structure the announcement
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
so I recommend trying them out.
|
||||
<br />
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(vscodeUriScheme)}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "apiConfiguration",
|
||||
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Switch to OpenRouter
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
|
||||
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
|
||||
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
|
||||
</li>
|
||||
<li>
|
||||
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
|
||||
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
</li>
|
||||
<li>
|
||||
When Cline runs commands, you can now type directly in the terminal (+ support for Python
|
||||
environments)
|
||||
</li>
|
||||
</ul>*/}
|
||||
<div style={hrStyle} />
|
||||
<p style={linkContainerStyle}>
|
||||
Join us on{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://x.com/cline">
|
||||
X,
|
||||
</VSCodeLink>{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
|
||||
discord,
|
||||
</VSCodeLink>{" "}
|
||||
or{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
|
||||
r/cline
|
||||
</VSCodeLink>
|
||||
for more updates!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</announcement-component>
|
||||
|
||||
|
||||
We have a changeset PR that automatically generated as new unreleased PRs are merged into main, the PR is always called "Changeset version bump" and the author is github-actions.
|
||||
|
||||
The Changeset PR description looks something like this:
|
||||
|
||||
<changeset-pr-description>
|
||||
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing). If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
|
||||
|
||||
|
||||
# Releases
|
||||
## claude-dev@3.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c6e8b04: Recent task list is now collapsible, allowing users to hide their recent tasks (e.g. when sharing their screen).
|
||||
- aabe4ae: Add detection for new users to display special components
|
||||
- 6c18d51: adds global endpoint for vertex ai users
|
||||
- 080ed7c: Add Tailwind CSS IntelliSense to the the recommended extensions list
|
||||
- 5147e28: new workflow feature
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c0b3c69: fix eternal loading states when the last message is a checkpoint
|
||||
- 570ece3: selectImages protos migration
|
||||
- 8d8452e: askResponse protobus migration
|
||||
- cd1ff2a: Finishing the migration of Vscode Advanced settings to Settings Webview
|
||||
</changeset-pr-description>
|
||||
|
||||
The changeset pr is ALWAYS on the following branch: `changeset-release/main`.
|
||||
|
||||
I have the `gh` command line tool set up and authenticated, so you have everything you need.
|
||||
|
||||
The first step is to get the full diff from the changeset PR to look at the changes that were automatically made to the `CHANGELOG.md` file. By default it will automatically add a new section to the changelog.md file with the new version. The problem with the automatically generated section is that it just takes the text that the developers threw into their changeset files for each corresponding PR, and they can be pretty vague and bad. Additionally there's some stuff that is totally irrelevant for the end user, like minor refactoring changes. So I manually typically go in and update this section to be a proper changelog that will show up in our patchnotes. You can look at how the rest of the file is done because those are all good examples of us updating this to use good language for the end user. We usually put new features up top (and the most exciting flagship features at the very top), and then bug fixes/improvements at the bottom. Having some basic organization to the ordering of the bullet points by content is nice. But use common sense.
|
||||
|
||||
To handle this process effectively, do the following:
|
||||
|
||||
For each of the automatically generated bullet points in the Changelog.md, you should
|
||||
1. Take the commit hash at the start of the bullet point, and use the `gh` command line tool find the PR that it was associated with.
|
||||
2. Use the `gh` command to get the PR title/description/discussion to understand the context surrounding the PR.
|
||||
3. Use the `gh` command line tool to get the full PR diff to fully understand the changes made in the code.
|
||||
4. Synthesize that knowledge to determine (a) whether or not this change is relevant to end users and (b) what the text & ordering of the line should be.
|
||||
5. Update the `CHANGELOG.md` accordingly
|
||||
|
||||
Do this for every single item in the list from the autogenerated bullet points. We want to be diligent and have a full understanding of every feature so we can make the best changelog ever!
|
||||
|
||||
Here are some principles for good changelogs from keepchangelog.com, a handy guide:
|
||||
|
||||
<keepachangelog-pinciples-for-good-changelogs>
|
||||
### Guiding Principles
|
||||
- Changelogs are for humans, not machines.
|
||||
- There should be an entry for every single version.
|
||||
- The same types of changes should be grouped.
|
||||
- The latest version comes first.
|
||||
|
||||
### Bullet points in the changelog should follow these principles:
|
||||
- Types of changes
|
||||
- Added for new features.
|
||||
- Changed for changes in existing functionality.
|
||||
- Deprecated for soon-to-be removed features.
|
||||
- Removed for now removed features.
|
||||
- Fixed for any bug fixes.
|
||||
- Security in case of vulnerabilities.
|
||||
</keepachangelog-pinciples-for-good-changelogs>
|
||||
|
||||
Lastly, when developers make a PR, they typically make a changeset. And they have 3 options when making the changeset:
|
||||
|
||||
1. Patch
|
||||
2. Minor
|
||||
3. Major
|
||||
|
||||
Sometimes they label something as minor when really it should just be a patch. Or vice versa. Because of this, the automatic version bump may be incorrect. So when starting out this workflow, you should use the <ask_followup_question> tool to confirm with me whether or not this should be a patch bump (show the old version number and what the proposed new version number would be) or a minor bump. Part of the release process is making sure the version in package.json that is automatically changed actually corresponds with what we decided the bump should actually be based on the features. ALL these modifications happen in the `changeset-release/main` branch btw.
|
||||
|
||||
<important_note>
|
||||
Before doing any of this, make sure you check out the `changeset-release/main` and pull the most recent up to date changes. Then perform all this work in that branch.
|
||||
|
||||
New announcement banners should ONLY be made for minor version bumps or higher. That's another reason why double checking if the changelog warrants the bump is important.
|
||||
|
||||
Also, SUPER important: For any external contributors that aren't part of the cline github organization, we always want to add a (Thanks @username!) at the end of the changelog to attribute them properly. We're an open source project and it's ethical to do this.
|
||||
</important_note>
|
||||
|
||||
Once the changelog looks good, and the version number looks good, we gotta double check that the version number in the changelog has the brackets around it. And as a final step, double check the package.json version number matches the latest number in the changelog. And as the ultimate final step we run `npm run install:all` to make sure the package version number permiates through the lock file.
|
||||
|
||||
|
||||
<detailed_sequence_of_steps>
|
||||
# Cline Release Process - Detailed Sequence of Steps
|
||||
|
||||
## Before Starting
|
||||
1. First, examine the changeset PR without checking it out:
|
||||
```bash
|
||||
gh pr view changeset-release/main
|
||||
```
|
||||
|
||||
2. View the PR diff to see the auto-generated CHANGELOG.md changes:
|
||||
```bash
|
||||
gh pr diff changeset-release/main > changeset-diff.txt
|
||||
cat changeset-diff.txt | grep -A 50 "CHANGELOG.md"
|
||||
```
|
||||
|
||||
## Initial Setup
|
||||
3. Once you're ready to start, checkout and update the changeset release branch:
|
||||
```bash
|
||||
git checkout changeset-release/main
|
||||
git pull origin changeset-release/main
|
||||
```
|
||||
|
||||
## Analyzing Each Change
|
||||
4. For each commit hash in the auto-generated changelog entries:
|
||||
|
||||
a. Find the PR number associated with a commit hash:
|
||||
```bash
|
||||
gh pr list --search "<commit-hash>" --state merged
|
||||
```
|
||||
|
||||
b. Get PR details for better context:
|
||||
```bash
|
||||
gh pr view <PR-number>
|
||||
```
|
||||
|
||||
c. Check if the contributor is external to determine if attribution is needed:
|
||||
```bash
|
||||
# Extract username from PR
|
||||
USERNAME=$(gh pr view <PR-number> --json author --jq .author.login)
|
||||
|
||||
# Check if user is a member of the Cline organization
|
||||
# this command is a bit finnicky, but it 100% works.
|
||||
# if you see a `Error executing command: The command ran successfully, but we couldn't capture its output. Please proceed accordingly.` error, just retry it until you actually get the output
|
||||
# don't make any assumptions, just retry the command to actually get the output and determine if they're external or not.
|
||||
# no output means they are an external contributor, otherwise if there is output they are an internal contributor (part of our github org)
|
||||
gh api "orgs/cline/members" --jq "map(.login)" | grep -i "pashpashpash"
|
||||
```
|
||||
|
||||
d. View the full PR diff to understand code changes:
|
||||
```bash
|
||||
gh pr diff <PR-number> > pr-diff-<PR-number>.txt
|
||||
cat pr-diff-<PR-number>.txt
|
||||
```
|
||||
|
||||
## Updating the Changelog
|
||||
5. Based on PR analysis, update the CHANGELOG.md with user-friendly descriptions:
|
||||
- Use the `<replace_in_file>` tool to edit the CHANGELOG.md file
|
||||
- Group by feature type (Added, Changed, Fixed)
|
||||
- Put most exciting features at the top
|
||||
- Move bug fixes and small improvements to the bottom
|
||||
- Use clear, end-user focused language
|
||||
- For external contributors, add attribution at the end of the relevant entry: `(Thanks @username!)`
|
||||
|
||||
## Version Number Verification
|
||||
6. Confirm the version bump is appropriate:
|
||||
- Check package.json to verify the auto-generated version number:
|
||||
```bash
|
||||
cat package.json | grep "\"version\""
|
||||
```
|
||||
- If the feature set doesn't warrant a minor bump, use the `<replace_in_file>` tool to modify package.json
|
||||
|
||||
7. Ensure the version in CHANGELOG.md has brackets around it:
|
||||
```
|
||||
## [3.16.0]
|
||||
```
|
||||
|
||||
## Creating the Announcement (for minor/major versions only)
|
||||
8. If this is a minor version bump, create/update the announcement component:
|
||||
- Use the `<replace_in_file>` tool to edit the src/views/components/announcement.tsx file
|
||||
- Update the highlights based on key features
|
||||
- Move previous version highlights to the "Previous Updates" section
|
||||
- Use the previous announcement components as reference for structure
|
||||
|
||||
## Finalizing the Release
|
||||
9. Update dependencies with the new version number:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
10. Commit your changes:
|
||||
```bash
|
||||
git add CHANGELOG.md package.json package-lock.json src/views/components/announcement.tsx
|
||||
git commit -m "Update CHANGELOG.md and announcement for version 3.16.0"
|
||||
```
|
||||
|
||||
11. Push your changes to the changeset branch:
|
||||
```bash
|
||||
git push origin changeset-release/main
|
||||
```
|
||||
|
||||
12. Check that your changes pushed successfully:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
</detailed_sequence_of_steps>
|
||||
@@ -89,16 +89,9 @@ On the main branch, create a commit that updates:
|
||||
|
||||
2. **package.json** - Update the version field to the new version
|
||||
|
||||
3. **Delete changesets** for the commits being included in the hotfix. This prevents the changeset bot from including duplicate entries in the next regular release.
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
Find and delete the changeset files associated with the selected commits:
|
||||
```bash
|
||||
ls .changeset/
|
||||
```
|
||||
|
||||
Each changeset file in `.changeset/` corresponds to a PR. Read them to identify which ones belong to the commits you're hotfixing, then delete those files.
|
||||
|
||||
**Skip running `npm run install:all`** - the automation handles outdated lockfiles.
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
@@ -107,7 +100,7 @@ In the commit body, mention:
|
||||
- List the cherry-picked commits that will be included
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md package.json .changeset/
|
||||
git add CHANGELOG.md package.json
|
||||
git commit -m "v3.40.1 Release Notes (hotfix)
|
||||
|
||||
Hotfix release including:
|
||||
|
||||
@@ -347,8 +347,6 @@ A few notes:
|
||||
So until the settings page is update, and this is added to settings in a way that's clean and doesn't confuse new users, I don't think we can merge this. Please bear with us.
|
||||
</request_changes_comment>
|
||||
<request_changes_comment>
|
||||
Also, don't forget to add a changeset since this fixes a user-facing bug.
|
||||
|
||||
The architectural change is solid - moving the focus logic to the command handlers makes sense. Just don't want to introduce subtle timing issues by removing those timeouts.
|
||||
</request_changes_comment>
|
||||
</example_comments_that_i_have_written_before>
|
||||
|
||||
@@ -1,232 +1,64 @@
|
||||
# Release
|
||||
|
||||
Prepare and publish a release from the open changeset PR.
|
||||
Prepare and publish a release directly from `main`.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow helps you:
|
||||
1. Find and checkout the open changeset PR
|
||||
2. Clean up the changelog (fix version format, wordsmith entries)
|
||||
3. Push changes back to the PR branch
|
||||
4. Merge with proper commit message format
|
||||
5. Tag and push the release (after verifying the commit)
|
||||
6. Trigger the publish workflow
|
||||
7. Update GitHub release notes
|
||||
8. Provide final summary with Slack announcement
|
||||
1. Select/confirm the target version
|
||||
2. Curate `CHANGELOG.md` entries manually for end users
|
||||
3. Ensure `package.json` version matches the changelog
|
||||
4. Create and push a release commit + tag
|
||||
5. Trigger publish workflow
|
||||
6. Update GitHub release notes and share a summary
|
||||
|
||||
## Step 1: Find the Changeset PR
|
||||
## Process
|
||||
|
||||
Look for the open changeset PR:
|
||||
|
||||
```bash
|
||||
gh pr list --search "Changeset version bump" --state open --json number,title,headRefName,url
|
||||
```
|
||||
|
||||
If no PR is found, inform the user there's no changeset PR ready. They may need to:
|
||||
- Merge PRs with changesets to main first
|
||||
- Manually trigger the Changeset Converter workflow at: https://github.com/cline/cline/actions/workflows/changeset-converter.yml
|
||||
|
||||
## Step 2: Gather PR Information
|
||||
|
||||
Get the PR details:
|
||||
|
||||
```bash
|
||||
PR_NUMBER=<number from step 1>
|
||||
gh pr view $PR_NUMBER --json body,files,headRefName
|
||||
```
|
||||
|
||||
Checkout the PR branch:
|
||||
|
||||
```bash
|
||||
git fetch origin changeset-release/main
|
||||
git checkout changeset-release/main
|
||||
```
|
||||
|
||||
If the branch has diverged from remote, reset to the remote version:
|
||||
|
||||
```bash
|
||||
git reset --hard origin/changeset-release/main
|
||||
```
|
||||
|
||||
## Step 3: Analyze the Changes
|
||||
|
||||
Read the current CHANGELOG.md to see what the automation generated:
|
||||
|
||||
```bash
|
||||
head -50 CHANGELOG.md
|
||||
```
|
||||
|
||||
Get the version from package.json:
|
||||
|
||||
```bash
|
||||
cat package.json | grep '"version"'
|
||||
```
|
||||
|
||||
**Present to the user:**
|
||||
- The version number that will be released
|
||||
- The raw changelog entries from the changeset PR
|
||||
- Whether this is a patch, minor, or major release
|
||||
|
||||
## Step 4: Clean Up the Changelog
|
||||
|
||||
The changelog needs these fixes:
|
||||
|
||||
1. **Add brackets to version number**: Change `## 3.44.1` to `## [3.44.1]`
|
||||
|
||||
2. **No category headers**: Don't use `### Added`, `### Fixed`, etc. Just a flat list of bullet points.
|
||||
|
||||
3. **Order entries from most important to least important**:
|
||||
- Lead with major new features or significant fixes users care about
|
||||
- End with minor fixes or internal changes
|
||||
|
||||
4. **Write user-friendly descriptions**:
|
||||
- This is for end users, not developers—explain what changed in plain language
|
||||
- Remove commit hashes from the beginning of lines (the automation adds these)
|
||||
- Look at the actual commit diffs (`git show <hash>`) and PRs to understand what changed
|
||||
- Write colorful descriptions that explain the value and impact, not just technical details
|
||||
- Consolidate related changes into single entries when appropriate
|
||||
|
||||
**Ask the user** to review the proposed changelog changes before applying them. Show them:
|
||||
- Current (raw) changelog section
|
||||
- Proposed (cleaned) changelog section
|
||||
|
||||
Once approved, apply the changes to CHANGELOG.md.
|
||||
|
||||
## Step 5: Commit and Push Changes
|
||||
|
||||
After making changelog edits:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Clean up changelog formatting"
|
||||
git push origin changeset-release/main
|
||||
```
|
||||
|
||||
## Step 6: Merge the PR
|
||||
|
||||
**Ask the user to confirm** they're ready to merge.
|
||||
|
||||
Merge the PR with the proper commit message format:
|
||||
|
||||
```bash
|
||||
VERSION=<version from package.json>
|
||||
gh pr merge $PR_NUMBER --squash --subject "v${VERSION} Release Notes" --body ""
|
||||
```
|
||||
|
||||
**If merge is blocked by branch protection:**
|
||||
- Users with admin privileges can add the `--admin` flag to bypass
|
||||
- Users without admin privileges need to get the PR approved through normal review first before merging
|
||||
|
||||
## Step 7: Tag the Release
|
||||
|
||||
After the merge completes, checkout main and pull:
|
||||
### 1) Sync and determine version
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
cat package.json | grep '"version"'
|
||||
```
|
||||
|
||||
**IMPORTANT: Verify the latest commit is the release commit before tagging:**
|
||||
Confirm the release version with the maintainer (patch/minor/major).
|
||||
|
||||
### 2) Curate changelog and version
|
||||
|
||||
- Edit `CHANGELOG.md` for the target version using human-friendly release notes.
|
||||
- Ensure version headers use bracket format, e.g. `## [3.66.1]`.
|
||||
- Update `package.json` version to the same value.
|
||||
|
||||
### 3) Commit and tag
|
||||
|
||||
```bash
|
||||
git log -1 --oneline
|
||||
git add CHANGELOG.md package.json package-lock.json
|
||||
git commit -m "v<version> Release Notes"
|
||||
git push origin main
|
||||
git tag v<version>
|
||||
git push origin v<version>
|
||||
```
|
||||
|
||||
Confirm the commit message matches `v{VERSION} Release Notes` (e.g., `v3.44.1 Release Notes`). Do NOT blindly tag HEAD without verification.
|
||||
### 4) Trigger publish workflow
|
||||
|
||||
Once verified, tag and push:
|
||||
Tell the maintainer to run:
|
||||
https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
|
||||
Use `v<version>` as the release tag.
|
||||
|
||||
### 5) Update GitHub release notes
|
||||
|
||||
After publish completes:
|
||||
|
||||
```bash
|
||||
VERSION=<version>
|
||||
git tag v${VERSION}
|
||||
git push origin v${VERSION}
|
||||
gh release view v<version> --json body --jq '.body'
|
||||
gh release edit v<version> --notes "<final curated release notes>"
|
||||
```
|
||||
|
||||
## Step 8: Trigger Publish Workflow
|
||||
### 6) Final summary
|
||||
|
||||
**Copy the tag to clipboard** so the user can easily paste it into the GitHub Actions workflow:
|
||||
|
||||
```bash
|
||||
echo -n "v{VERSION}" | pbcopy
|
||||
```
|
||||
|
||||
**Tell the user to trigger the publish workflow:**
|
||||
1. Go to: https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
2. Select **"release"** for release-type
|
||||
3. Paste **`v{VERSION}`** as the tag (already in clipboard)
|
||||
|
||||
**Wait for the user** to confirm the publish workflow has completed before proceeding.
|
||||
|
||||
## Step 9: Update GitHub Release Notes
|
||||
|
||||
Once the user confirms the publish workflow is done, fetch the auto-generated release content:
|
||||
|
||||
```bash
|
||||
VERSION=<version>
|
||||
gh release view v${VERSION} --json body --jq '.body'
|
||||
```
|
||||
|
||||
The auto-generated release has:
|
||||
- `## What's Changed` - PR list (we'll replace this with our changelog)
|
||||
- `## New Contributors` - First-time contributors (keep this if present)
|
||||
- `**Full Changelog**` - Comparison link (keep this)
|
||||
|
||||
Build the new release body:
|
||||
1. Start with `## What's Changed` header
|
||||
2. Add our changelog content (from CHANGELOG.md for this version)
|
||||
3. Keep the `## New Contributors` section if it exists
|
||||
4. Keep the `**Full Changelog**` link
|
||||
|
||||
Update the release:
|
||||
|
||||
```bash
|
||||
gh release edit v${VERSION} --notes "<new body content>"
|
||||
```
|
||||
|
||||
Verify the release was updated:
|
||||
|
||||
```bash
|
||||
gh release view v${VERSION}
|
||||
```
|
||||
|
||||
## Step 10: Final Summary
|
||||
|
||||
**Copy a Slack announcement message to clipboard** (include the full changelog, not just highlights):
|
||||
|
||||
```bash
|
||||
echo "VS Code v{VERSION} Released
|
||||
|
||||
- Changelog entry 1
|
||||
- Changelog entry 2
|
||||
- Changelog entry 3" | pbcopy
|
||||
```
|
||||
|
||||
**Present a final summary:**
|
||||
- Version released: v{VERSION}
|
||||
- PR merged: #{PR_NUMBER}
|
||||
- Tag pushed: v{VERSION}
|
||||
- Release: https://github.com/cline/cline/releases/tag/v{VERSION}
|
||||
- Slack message copied to clipboard
|
||||
|
||||
**Final reminder:**
|
||||
Post the Slack message to announce the release
|
||||
|
||||
## Handling Edge Cases
|
||||
|
||||
### No changesets found
|
||||
If the changeset PR body shows no changes, inform the user they need to merge PRs with changesets first.
|
||||
|
||||
### Merge conflicts
|
||||
If there are conflicts on the changeset branch, help the user resolve them:
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
# resolve conflicts
|
||||
git push origin changeset-release/main --force-with-lease
|
||||
```
|
||||
|
||||
### User wants to add more changes
|
||||
If the user wants to include additional PRs before releasing:
|
||||
1. Ask them to merge those PRs to main first
|
||||
2. The changeset automation will update the PR automatically
|
||||
3. Re-run this workflow after the PR is updated
|
||||
Provide:
|
||||
- Released version/tag
|
||||
- Link to release page
|
||||
- Summary of top end-user changes
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "cline"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
if [ ! -d "node_modules" ]; then
|
||||
MAIN_WORKTREE="$(git worktree list | head -n1 | awk '{print $1}')"
|
||||
ln -s "$MAIN_WORKTREE/node_modules" node_modules
|
||||
ln -s "$MAIN_WORKTREE/webview-ui/node_modules" webview-ui/node_modules
|
||||
fi
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "VS Code"
|
||||
icon = "run"
|
||||
command = "chmod +x ./scripts/run-extension-host.sh && ./scripts/run-extension-host.sh production"
|
||||
|
||||
[[actions]]
|
||||
name = "CLI"
|
||||
icon = "run"
|
||||
command = '''
|
||||
npm run cli:build
|
||||
npm run cli:run
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "npm install"
|
||||
icon = "tool"
|
||||
command = '''
|
||||
rm node_modules
|
||||
rm webview-ui/node_modules
|
||||
npm run install:all
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "pull main"
|
||||
icon = "tool"
|
||||
command = '''
|
||||
git fetch origin main
|
||||
|
||||
if ! git merge-base --is-ancestor main origin/main; then
|
||||
echo "Local main has commits not on origin/main. Aborting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git update-ref refs/heads/main refs/remotes/origin/main
|
||||
echo "main updated to $(git rev-parse --short main)"
|
||||
'''
|
||||
@@ -60,7 +60,6 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] I have created a changeset using `npm run changeset` (required for user-facing changes)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
This script updates a specific version's release notes section in CHANGELOG.md with new content
|
||||
or reformats existing content.
|
||||
|
||||
The script:
|
||||
1. Takes a version number, changelog path, and optionally new content as input from environment variables
|
||||
2. Finds the section in the changelog for the specified version
|
||||
3. Either:
|
||||
a) Replaces the content with new content if provided, or
|
||||
b) Reformats existing content by:
|
||||
- Removing the first two lines of the changeset format
|
||||
- Ensuring version numbers are wrapped in square brackets
|
||||
4. Writes the updated changelog back to the file
|
||||
|
||||
Environment Variables:
|
||||
CHANGELOG_PATH: Path to the changelog file (defaults to 'CHANGELOG.md')
|
||||
VERSION: The version number to update/format
|
||||
PREV_VERSION: The previous version number (used to locate section boundaries)
|
||||
NEW_CONTENT: Optional new content to insert for this version
|
||||
"""
|
||||
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
|
||||
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
|
||||
VERSION = os.environ['VERSION']
|
||||
PREV_VERSION = os.environ.get("PREV_VERSION", "")
|
||||
NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
|
||||
|
||||
def overwrite_changelog_section(changelog_text: str, new_content: str):
|
||||
# Find the section for the specified version
|
||||
version_pattern = f"## {VERSION}\n"
|
||||
unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n"
|
||||
prev_version_pattern = f"## [{PREV_VERSION}]\n"
|
||||
print(f"latest version: {VERSION}")
|
||||
print(f"prev_version: {PREV_VERSION}")
|
||||
|
||||
notes_start_index = changelog_text.find(version_pattern) + len(version_pattern)
|
||||
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
|
||||
|
||||
if new_content:
|
||||
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
|
||||
else:
|
||||
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
|
||||
filtered_lines = []
|
||||
for line in changeset_lines:
|
||||
# If the previous line is a changeset format
|
||||
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
|
||||
# Remove the last two lines from the filted_lines
|
||||
filtered_lines.pop()
|
||||
filtered_lines.pop()
|
||||
else:
|
||||
filtered_lines.append(line.strip())
|
||||
|
||||
# Prepend a new line to the first line of filtered_lines
|
||||
if filtered_lines:
|
||||
filtered_lines[0] = "\n" + filtered_lines[0]
|
||||
|
||||
# Print filted_lines wiht a "\n" at the end of each line
|
||||
for line in filtered_lines:
|
||||
print(line.strip())
|
||||
|
||||
parsed_lines = "\n".join(line for line in filtered_lines)
|
||||
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
|
||||
return updated_changelog
|
||||
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# print(new_changelog)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# Write back to CHANGELOG.md
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
@@ -1,113 +0,0 @@
|
||||
name: Changeset Converter
|
||||
run-name: Changeset Conversion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
NODE_VERSION: 20.18.1
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'github-actions'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check user for team affiliation
|
||||
id: team_check
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
org: ${{ github.repository_owner }}
|
||||
team: "deployer"
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if user is authorized
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
|
||||
echo "User is not authorized to run this workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
id: check-changesets
|
||||
run: |
|
||||
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
|
||||
echo "Changesets diff with previous version: $NEW_CHANGESETS"
|
||||
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create version bump PR using changesets/action if there are new changesets
|
||||
- name: Create Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: "changeset version bump"
|
||||
title: "Changeset version bump"
|
||||
version: npm run version-packages # This performs the changeset version bump
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Get current and previous versions to edit changelog entry
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(git show HEAD:package.json | jq -r '.version')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
|
||||
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION"
|
||||
echo "prev_version=$PREV_VERSION"
|
||||
|
||||
# Update CHANGELOG.md with proper format
|
||||
- name: Update Changelog Format
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
|
||||
run: python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
# Commit and push changelog updates
|
||||
- name: Push Changelog updates to Pull Request
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email github-actions@github.com
|
||||
echo "Running git add and commit..."
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Updating CHANGELOG.md format"
|
||||
git status
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
echo "Pushing to remote..."
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
git push origin $CURRENT_BRANCH
|
||||
@@ -1,173 +0,0 @@
|
||||
name: Claude Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# Manual trigger for backfilling existing issues. Run from terminal:
|
||||
# gh workflow run claude-issue-triage.yml -f issue_number=1234
|
||||
# Or batch process:
|
||||
# gh issue list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run claude-issue-triage.yml -f issue_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number to triage'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-issue-triage:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
|
||||
# - issues: write -> Claude can comment and add labels (the only write access needed)
|
||||
# - pull-requests: read -> Claude can view PR context but CANNOT create PRs
|
||||
# This ensures that even if a malicious user attempts prompt injection via issue content,
|
||||
# Claude cannot modify repository code, create branches, or open PRs.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Issue Response & Triage
|
||||
id: triage
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
# Allow all tools - security is enforced by GitHub permissions above (contents: read, issues: write)
|
||||
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
|
||||
prompt: |
|
||||
You're a GitHub issue first responder for the open source Cline repository.
|
||||
|
||||
**Issue:** #${{ github.event.issue.number || inputs.issue_number }}
|
||||
**Title:** ${{ github.event.issue.title || 'See issue details below' }}
|
||||
**Author:** @${{ github.event.issue.user.login || 'See issue details below' }}
|
||||
|
||||
## Your job
|
||||
|
||||
Investigate this issue thoroughly, then post a single helpful comment that helps the user and gives maintainers the context they need.
|
||||
|
||||
## Investigation
|
||||
|
||||
Start by reading the full issue:
|
||||
gh issue view ${{ github.event.issue.number || inputs.issue_number }}
|
||||
|
||||
### Search for duplicates and related issues
|
||||
|
||||
Search thoroughly for existing issues that match this one:
|
||||
gh issue list --search "<keywords from the issue>" --state all --limit 30
|
||||
gh issue list --search "<error messages>" --state all --limit 20
|
||||
gh issue list --search "<affected feature/component>" --state all --limit 20
|
||||
|
||||
For each relevant issue you find, read it including its comments:
|
||||
gh issue view <number> --comments
|
||||
|
||||
You're looking for:
|
||||
- **Duplicates**: Issues describing the same problem. Link to them and explain why you think they're duplicates. If closed, check how they were resolved - the solution might apply here.
|
||||
- **Related issues**: Similar problems or context that could help. Pull useful information from their comments (workarounds others found, debugging steps that helped, maintainer explanations). Link to them and explain the connection.
|
||||
|
||||
If there are closed issues with solutions, surface those solutions prominently - this might immediately solve the user's problem.
|
||||
|
||||
### Analyze recent changes (ALWAYS DO THIS)
|
||||
|
||||
Many issues are regressions from recent releases. **Always** check what changed recently:
|
||||
gh release list --limit 10
|
||||
gh pr list --state merged --limit 50 --json number,title,mergedAt,author,body
|
||||
|
||||
Look for PRs merged in the last few weeks that might correlate with the issue. If you find a likely connection:
|
||||
gh pr view <number>
|
||||
gh pr diff <number>
|
||||
git log --since="1 month ago" --oneline -- <relevant paths>
|
||||
git show <commit>
|
||||
|
||||
**Always include your findings in your comment:**
|
||||
- If you find a regression, call it out explicitly: which PR/commit likely caused it, who authored it, what changed, and suggest a fix direction if you can see one.
|
||||
- If you don't find anything related, still mention it: "I analyzed recent PRs and releases but didn't find any changes that seem related to this issue."
|
||||
|
||||
### Search the codebase
|
||||
|
||||
Find the relevant code:
|
||||
- Use grep/find to locate code related to the issue
|
||||
- Key areas: `src/api/` (providers/models), `src/core/prompts/` (tools/prompts), platform-specific code for VS Code vs JetBrains
|
||||
|
||||
### Find documentation
|
||||
|
||||
Cline docs are at **https://docs.cline.bot/** and built with Mintlify from the `docs/` directory.
|
||||
|
||||
The URL structure maps directly to the file structure:
|
||||
- `docs/getting-started/selecting-your-model.mdx` → https://docs.cline.bot/getting-started/selecting-your-model
|
||||
- `docs/troubleshooting.mdx` → https://docs.cline.bot/troubleshooting
|
||||
- Headings become anchors: `## Which Model` → `#which-model`
|
||||
|
||||
Search the `docs/` directory to find relevant documentation, then construct URLs to link users to:
|
||||
```bash
|
||||
ls docs/
|
||||
grep -r "keyword" docs/ --include="*.mdx" -l
|
||||
```
|
||||
|
||||
### Identify subject matter experts
|
||||
|
||||
For issues that clearly need engineering attention:
|
||||
git log --since="6 months ago" --format="%an" -- <relevant paths> | sort | uniq -c | sort -rn | head -5
|
||||
|
||||
Cross-reference with GitHub usernames. Include in your response (@mention, do NOT assign):
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file |
|
||||
|
||||
## Weak model detection
|
||||
|
||||
Many issues are caused by users running small or non-frontier models that don't tool-call reliably. Signs include:
|
||||
- Model failing to use tools correctly
|
||||
- Nonsensical or malformed responses
|
||||
- User is running a small/local model or older model version
|
||||
|
||||
If this looks like a weak model issue, kindly suggest they try reproducing with Claude Sonnet and report back if it persists. Link to https://docs.cline.bot/getting-started/selecting-your-model if helpful. Still label and triage normally.
|
||||
|
||||
## Your comment
|
||||
|
||||
Write a single comment as a helpful community member. Be conversational, not robotic. Include what's relevant:
|
||||
|
||||
- **Helpful response** - Answer their question, suggest a fix, provide a workaround. If you found solutions in related closed issues, surface those prominently.
|
||||
- **Duplicates and related issues** - Link to any you found and explain why they're duplicates/related. Summarize useful context from their comments.
|
||||
- **Regression analysis** - If this looks like a regression, explain what change likely caused it, link to the PR/commit, and tag the author.
|
||||
- **Clarifying questions** - If you need more info, ask specific questions. Don't ask for things already provided.
|
||||
- **SME table** - Include the table above if this needs engineering attention. Don't tag people for questions with obvious answers or weak-model issues.
|
||||
- **Context for maintainers** - Relevant code paths, what you found. Keep it concise.
|
||||
- **Docs links** - If there's relevant documentation, link to it naturally in your response as a recommendation (e.g., "For more details, check out [the Ollama setup guide](url)"). Do NOT add a "Sources" section at the end - integrate doc links into your response where they're helpful.
|
||||
- **Possible Duplicates section** - ALWAYS include a "Possible Duplicates" section at the end of your comment listing issues that might be duplicates so maintainers can quickly close if appropriate. If none found, say "No obvious duplicates found."
|
||||
|
||||
## Labels
|
||||
First, retrieve all available labels and read their descriptions to understand what each is for:
|
||||
gh label list --json name,description --limit 100
|
||||
|
||||
Then apply the appropriate labels based on your analysis. Only use labels from the list above—do not create new labels.
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "label1,label2"
|
||||
|
||||
If your regression analysis found a likely culprit (a recent PR/commit that probably caused this issue), add the "Regression" label:
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Regression"
|
||||
|
||||
IMPORTANT: After posting your comment, add the "Bot Responded" label to indicate this issue has received an automated response:
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Bot Responded"
|
||||
|
||||
## Remember
|
||||
|
||||
- **This is a one-time automated response** - you will NOT see their reply or respond again. Never say things like "I can help you", "let me know", "once I have that info", or "I can give you more targeted help" - you won't be there to follow up. If you ask clarifying questions, frame them for the maintainers who will follow up, e.g., "If you can share X, that would help the maintainers diagnose this."
|
||||
- Don't be formulaic. Respond to what the issue actually needs.
|
||||
- Surface solutions from past issues - often the fastest path to helping.
|
||||
- Connecting regressions to specific changes is extremely valuable.
|
||||
- Link issues with #number so they're clickable.
|
||||
@@ -1,284 +0,0 @@
|
||||
name: Claude PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review]
|
||||
# Manual trigger for backfilling existing PRs. Run from terminal:
|
||||
# gh workflow run claude-pr-review.yml -f pr_number=1234
|
||||
# Or batch process open PRs:
|
||||
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run claude-pr-review.yml -f pr_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-pr-review:
|
||||
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
|
||||
# - pull-requests: write -> Claude can post reviews and inline suggestions
|
||||
# - issues: read -> Claude can search for related issues
|
||||
# NOTE: Even with pull-requests: write, Claude CANNOT merge PRs because branch protection
|
||||
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Print HEAD commit
|
||||
run: |
|
||||
echo "HEAD is at: $(git rev-parse HEAD)"
|
||||
echo "Short: $(git rev-parse --short HEAD)"
|
||||
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
|
||||
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Run PR Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
|
||||
prompt: |
|
||||
You're a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
|
||||
|
||||
PR: #${{ steps.pr.outputs.number }}
|
||||
|
||||
## Gather context
|
||||
|
||||
```bash
|
||||
# Get full PR details
|
||||
gh pr view ${{ steps.pr.outputs.number }} --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
|
||||
|
||||
# Get the diff
|
||||
gh pr diff ${{ steps.pr.outputs.number }}
|
||||
|
||||
# Check CI status
|
||||
gh pr checks ${{ steps.pr.outputs.number }}
|
||||
|
||||
# Get existing review comments (to understand context and your previous feedback)
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments --jq '.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'
|
||||
|
||||
# Get conversation comments
|
||||
gh pr view ${{ steps.pr.outputs.number }} --comments
|
||||
```
|
||||
|
||||
If this is a re-review (workflow_dispatch event):
|
||||
Read your previous comments carefully. Understand what you asked for before.
|
||||
Check if new commits or comments address your previous feedback.
|
||||
|
||||
## Check contributing guidelines
|
||||
|
||||
Flag (but don't block) if:
|
||||
- Missing changeset - For user-facing changes, check if there's a `.changeset/` file:
|
||||
```bash
|
||||
gh pr diff ${{ steps.pr.outputs.number }} --name-only | grep '.changeset/' || echo "No changeset found"
|
||||
```
|
||||
If missing, ask them to run `npm run changeset`
|
||||
- Missing tests - New features should have tests
|
||||
|
||||
## Find related issues and PRs
|
||||
|
||||
Search thoroughly for context that might help with the review:
|
||||
|
||||
```bash
|
||||
# Find related issues for context
|
||||
gh issue list --search "<keywords from the PR>" --state all --limit 30
|
||||
gh issue list --search "<error messages or feature names>" --state all --limit 20
|
||||
|
||||
# Find similar PRs for reference
|
||||
gh pr list --search "<keywords>" --state all --limit 30
|
||||
```
|
||||
|
||||
For each relevant issue or PR you find, read it including comments:
|
||||
```bash
|
||||
gh issue view <number> --comments
|
||||
gh pr view <number> --comments
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Open issues this PR might fix that weren't linked in the description
|
||||
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
|
||||
- Context from maintainer discussions that could inform your review
|
||||
|
||||
## Find subject matter experts
|
||||
|
||||
For files changed in this PR, find who knows the code best:
|
||||
```bash
|
||||
# Get files changed
|
||||
gh pr diff ${{ steps.pr.outputs.number }} --name-only
|
||||
|
||||
# For each relevant path, find contributors
|
||||
git log --since="6 months ago" --format="%an" -- <path> | sort | uniq -c | sort -rn | head -5
|
||||
```
|
||||
|
||||
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
|
||||
| @username3 | Reviewed similar PR #Y with extensive feedback |
|
||||
|
||||
## Deep code review
|
||||
|
||||
This is the most important part. Don't just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
|
||||
|
||||
Step 1: Understand the intent
|
||||
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
|
||||
|
||||
Step 2: Form your own opinion first
|
||||
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
|
||||
|
||||
Step 3: Compare approaches
|
||||
Now look at their implementation. How does it compare to what you would have done?
|
||||
- Is their approach better in some ways? Note what they did well.
|
||||
- Is their approach missing something? Be specific about what and why.
|
||||
- Are there edge cases they haven't considered?
|
||||
- Does it follow the patterns established in similar parts of the codebase?
|
||||
|
||||
Step 4: Look at the bigger picture
|
||||
- What other files or systems does this change interact with?
|
||||
- Could this break anything else?
|
||||
- Is there additional work needed beyond this PR to complete the feature?
|
||||
- Does this fit well with the overall architecture?
|
||||
|
||||
Step 5: Find reference implementations
|
||||
Look for similar changes in the codebase:
|
||||
```bash
|
||||
git log --oneline --all --grep="<relevant keywords>" | head -20
|
||||
git log --oneline -- <similar files> | head -20
|
||||
```
|
||||
|
||||
If this is adding a new API provider, look at how other providers are implemented.
|
||||
If this is adding a new feature, look at how similar features were added.
|
||||
Note where their implementation aligns with or diverges from established patterns.
|
||||
|
||||
Step 6: Standard code review checks
|
||||
- DRY: Is there duplicated code that could be extracted?
|
||||
- Error handling: Are errors handled appropriately?
|
||||
- Security: Any injection risks, credential exposure, unsafe dependencies?
|
||||
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
|
||||
- Types: Is TypeScript used correctly? Any unsafe type assertions?
|
||||
- Naming: Are variables and functions named clearly?
|
||||
- Comments: Is complex logic explained? Are there outdated comments?
|
||||
|
||||
## Inline code suggestions
|
||||
|
||||
For specific code improvements, use GitHub's suggestion syntax via `gh api`.
|
||||
This creates suggestions the author can commit with one click.
|
||||
|
||||
Single-line suggestion:
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
|
||||
-f event="COMMENT" \
|
||||
-f body="" \
|
||||
-F comments='[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"line": 42,
|
||||
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Multi-line suggestion (replacing lines 40-45):
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
|
||||
-f event="COMMENT" \
|
||||
-f body="" \
|
||||
-F comments='[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"start_line": 40,
|
||||
"line": 45,
|
||||
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
|
||||
|
||||
## Post your review
|
||||
|
||||
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
|
||||
|
||||
Start by noting the commit hash you reviewed:
|
||||
```bash
|
||||
git rev-parse --short HEAD
|
||||
```
|
||||
Include this at the top of your comment: "Reviewed at commit: <short hash>"
|
||||
|
||||
Then thank them for their contribution. Be conversational, not robotic.
|
||||
|
||||
Include what's relevant:
|
||||
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author's intent, why they made the changes, how they implemented it, and what files/systems are affected. Don't just summarize - explain.
|
||||
- Related issues/PRs you found that provide useful context (link to them)
|
||||
- Your review findings (issues to address, suggestions, etc.)
|
||||
- Clear next steps for the author
|
||||
|
||||
Include a "For Maintainers" section with:
|
||||
- Anything else useful to help the maintainer resolve this PR
|
||||
- Related issues/PRs with context on why they're relevant
|
||||
- Open issues this PR might fix that weren't linked in the description
|
||||
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
|
||||
- SME table - who should review this and why
|
||||
|
||||
For the SME table:
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username | Primary contributor to affected files |
|
||||
|
||||
## Update labels
|
||||
|
||||
Add appropriate labels based on your analysis:
|
||||
```bash
|
||||
gh label list --json name,description --limit 100
|
||||
gh pr edit ${{ steps.pr.outputs.number }} --add-label "label1,label2"
|
||||
```
|
||||
|
||||
When done, add the reviewed label:
|
||||
```bash
|
||||
gh pr edit ${{ steps.pr.outputs.number }} --add-label "Bot Reviewed"
|
||||
```
|
||||
|
||||
## Remember
|
||||
|
||||
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like "let me know if you have questions", "I can help you with", or "feel free to ask" - you won't be there to follow up. Frame any questions for the maintainers who will follow up.
|
||||
- Be helpful and welcoming - Many contributors are new to the project
|
||||
- Be specific - Point to exact lines and suggest fixes, don't give vague feedback
|
||||
- Think deeply - Don't just surface-level review, understand the intent and evaluate the approach
|
||||
- Use inline suggestions - Make it easy for authors to accept changes
|
||||
- You're a first-pass reviewer - A human maintainer will do final approval
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Smoke Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/core/**'
|
||||
- 'src/shared/**'
|
||||
- 'proto/**'
|
||||
- 'evals/**'
|
||||
- '.github/workflows/cline-evals-regression.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/core/**'
|
||||
- 'src/shared/**'
|
||||
- 'proto/**'
|
||||
- 'evals/**'
|
||||
- '.github/workflows/cline-evals-regression.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: smoke-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
smoke-tests:
|
||||
name: Smoke Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and install CLI
|
||||
run: |
|
||||
npm run protos
|
||||
cd cli && npm install && npm run build && npm link
|
||||
echo "$(npm config get prefix)/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify CLI
|
||||
run: cline --version
|
||||
|
||||
- name: Run smoke tests
|
||||
env:
|
||||
CLINE_API_KEY: ${{ secrets.CLINE_API_KEY }}
|
||||
run: |
|
||||
cline auth -p cline -k "$CLINE_API_KEY" -m "anthropic/claude-sonnet-4.5"
|
||||
max_attempts=3
|
||||
for attempt in $(seq 1 $max_attempts); do
|
||||
echo "::group::Attempt $attempt of $max_attempts"
|
||||
if npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1 --parallel; then
|
||||
echo "::endgroup::"
|
||||
echo "Smoke tests passed on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ $attempt -lt $max_attempts ]; then
|
||||
echo "::warning::Smoke tests failed on attempt $attempt, retrying..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
echo "::error::Smoke tests failed after $max_attempts attempts"
|
||||
exit 1
|
||||
|
||||
- name: Generate summary
|
||||
if: always()
|
||||
run: cat evals/smoke-tests/results/latest/summary.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: smoke-test-results-${{ github.run_id }}
|
||||
path: evals/smoke-tests/results/latest/
|
||||
retention-days: 30
|
||||
@@ -1,325 +0,0 @@
|
||||
name: Cline PR Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
[opened, ready_for_review]
|
||||
# Manual trigger for backfilling existing PRs. Run from terminal:
|
||||
# gh workflow run cline-pr-review.yml -f pr_number=1234
|
||||
# Or batch process open PRs:
|
||||
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run cline-pr-review.yml -f pr_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to review"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: pr-review-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cline-pr-review:
|
||||
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> cline can read the codebase but CANNOT write/push any code
|
||||
# - pull-requests: write -> cline can post reviews and inline suggestions
|
||||
# - issues: read -> cline can search for related issues
|
||||
# NOTE: Even with pull-requests: write, cline CANNOT merge PRs because branch protection
|
||||
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Print HEAD commit
|
||||
run: |
|
||||
echo "HEAD is at: $(git rev-parse HEAD)"
|
||||
echo "Short: $(git rev-parse --short HEAD)"
|
||||
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install and Verify Cline CLI
|
||||
run: |
|
||||
npm install -g cline
|
||||
cline version # verify installation
|
||||
|
||||
- name: Configure Cline with Anthropic
|
||||
run: |
|
||||
npx cline auth --provider anthropic \
|
||||
--apikey "${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||
--modelid claude-opus-4-5-20251101
|
||||
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Review PR with Cline
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
GITHUB_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLINE_COMMAND_PERMISSIONS: |
|
||||
{
|
||||
"allow": [
|
||||
"gh pr diff *",
|
||||
"gh pr view *",
|
||||
"gh pr checks *",
|
||||
"gh pr list *",
|
||||
"gh label list *",
|
||||
"gh issue list *",
|
||||
"gh issue view *",
|
||||
"git log *",
|
||||
"gh pr comment ${{ steps.pr.outputs.number }} *",
|
||||
"gh pr edit ${{ steps.pr.outputs.number }} *",
|
||||
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments *",
|
||||
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews *"
|
||||
]
|
||||
}
|
||||
run: |
|
||||
npx cline --yolo 'You'\''re a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
|
||||
|
||||
PR: #'"${PR_NUMBER}"'
|
||||
|
||||
## Gather context
|
||||
|
||||
```bash
|
||||
# Get full PR details
|
||||
gh pr view '"${PR_NUMBER}"' --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
|
||||
|
||||
# Get the diff
|
||||
gh pr diff '"${PR_NUMBER}"'
|
||||
|
||||
# Check CI status
|
||||
gh pr checks '"${PR_NUMBER}"'
|
||||
|
||||
# Get existing review comments (to understand context and your previous feedback)
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/comments --jq '\''.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'\''
|
||||
|
||||
# Get conversation comments
|
||||
gh pr view '"${PR_NUMBER}"' --comments
|
||||
```
|
||||
|
||||
If this is a re-review (workflow_dispatch event):
|
||||
Read your previous comments carefully. Understand what you asked for before.
|
||||
Check if new commits or comments address your previous feedback.
|
||||
|
||||
## Check contributing guidelines
|
||||
|
||||
Flag (but don'\''t block) if:
|
||||
- Missing changeset - For user-facing changes, check if there'\''s a `.changeset/` file:
|
||||
```bash
|
||||
gh pr diff '"${PR_NUMBER}"' --name-only | grep '\''.changeset/'\'' || echo '\''No changeset found'\''
|
||||
```
|
||||
If missing, ask them to run `npm run changeset`
|
||||
- Missing tests - New features should have tests
|
||||
|
||||
## Find related issues and PRs
|
||||
|
||||
Search thoroughly for context that might help with the review:
|
||||
|
||||
```bash
|
||||
# Find related issues for context
|
||||
gh issue list --search '\''<keywords from the PR>'\'' --state all --limit 30
|
||||
gh issue list --search '\''<error messages or feature names>'\'' --state all --limit 20
|
||||
|
||||
# Find similar PRs for reference
|
||||
gh pr list --search '\''<keywords>'\'' --state all --limit 30
|
||||
```
|
||||
|
||||
For each relevant issue or PR you find, read it including comments:
|
||||
```bash
|
||||
gh issue view <number> --comments
|
||||
gh pr view <number> --comments
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Open issues this PR might fix that weren'\''t linked in the description
|
||||
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
|
||||
- Context from maintainer discussions that could inform your review
|
||||
|
||||
## Find subject matter experts
|
||||
|
||||
For files changed in this PR, find who knows the code best:
|
||||
```bash
|
||||
# Get files changed
|
||||
gh pr diff '"${PR_NUMBER}"' --name-only
|
||||
|
||||
# For each relevant path, find contributors
|
||||
git log --since='\''6 months ago'\'' --format='\''%an'\'' -- <path> | sort | uniq -c | sort -rn | head -5
|
||||
```
|
||||
|
||||
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
|
||||
| @username3 | Reviewed similar PR #Y with extensive feedback |
|
||||
|
||||
## Bash command usage
|
||||
|
||||
Don'\''t use operators like `|`, `&&`, or `;` - run each command separately and analyze the output.
|
||||
|
||||
When referencing command outputs, quote them properly to avoid formatting issues.
|
||||
|
||||
## Deep code review
|
||||
|
||||
This is the most important part. Don'\''t just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
|
||||
|
||||
Step 1: Understand the intent
|
||||
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
|
||||
|
||||
Step 2: Form your own opinion first
|
||||
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
|
||||
|
||||
Step 3: Compare approaches
|
||||
Now look at their implementation. How does it compare to what you would have done?
|
||||
- Is their approach better in some ways? Note what they did well.
|
||||
- Is their approach missing something? Be specific about what and why.
|
||||
- Are there edge cases they haven'\''t considered?
|
||||
- Does it follow the patterns established in similar parts of the codebase?
|
||||
|
||||
Step 4: Look at the bigger picture
|
||||
- What other files or systems does this change interact with?
|
||||
- Could this break anything else?
|
||||
- Is there additional work needed beyond this PR to complete the feature?
|
||||
- Does this fit well with the overall architecture?
|
||||
|
||||
Step 5: Find reference implementations
|
||||
Look for similar changes in the codebase:
|
||||
```bash
|
||||
git log --oneline --all --grep='\''<relevant keywords>'\'' | head -20
|
||||
git log --oneline -- <similar files> | head -20
|
||||
```
|
||||
|
||||
If this is adding a new API provider, look at how other providers are implemented.
|
||||
If this is adding a new feature, look at how similar features were added.
|
||||
Note where their implementation aligns with or diverges from established patterns.
|
||||
|
||||
Step 6: Standard code review checks
|
||||
- DRY: Is there duplicated code that could be extracted?
|
||||
- Error handling: Are errors handled appropriately?
|
||||
- Security: Any injection risks, credential exposure, unsafe dependencies?
|
||||
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
|
||||
- Types: Is TypeScript used correctly? Any unsafe type assertions?
|
||||
- Naming: Are variables and functions named clearly?
|
||||
- Comments: Is complex logic explained? Are there outdated comments?
|
||||
|
||||
## Inline code suggestions
|
||||
|
||||
For specific code improvements, use GitHub'\''s suggestion syntax via `gh api`.
|
||||
This creates suggestions the author can commit with one click.
|
||||
|
||||
Single-line suggestion:
|
||||
```bash
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
|
||||
-f event='\''COMMENT'\'' \
|
||||
-f body='\'''\'' \
|
||||
-F comments='\''[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"line": 42,
|
||||
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
|
||||
}
|
||||
]'\''
|
||||
```
|
||||
|
||||
Multi-line suggestion (replacing lines 40-45):
|
||||
```bash
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
|
||||
-f event='\''COMMENT'\'' \
|
||||
-f body='\'''\'' \
|
||||
-F comments='\''[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"start_line": 40,
|
||||
"line": 45,
|
||||
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
|
||||
}
|
||||
]'\''
|
||||
```
|
||||
|
||||
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
|
||||
|
||||
## Post your review
|
||||
|
||||
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
|
||||
|
||||
Start by noting the commit hash you reviewed:
|
||||
```bash
|
||||
git rev-parse --short HEAD
|
||||
```
|
||||
Include this at the top of your comment: "Reviewed at commit: <short hash>"
|
||||
|
||||
Then thank them for their contribution. Be conversational, not robotic.
|
||||
|
||||
Include what'\''s relevant:
|
||||
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author'\''s intent, why they made the changes, how they implemented it, and what files/systems are affected. Don'\''t just summarize - explain.
|
||||
- Related issues/PRs you found that provide useful context (link to them)
|
||||
- Your review findings (issues to address, suggestions, etc.)
|
||||
- Clear next steps for the author
|
||||
|
||||
Include a '\''For Maintainers'\'' section with:
|
||||
- Anything else useful to help the maintainer resolve this PR
|
||||
- Related issues/PRs with context on why they'\''re relevant
|
||||
- Open issues this PR might fix that weren'\''t linked in the description
|
||||
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
|
||||
- SME table - who should review this and why
|
||||
|
||||
For the SME table:
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username | Primary contributor to affected files |
|
||||
|
||||
## Update labels
|
||||
|
||||
Add appropriate labels based on your analysis:
|
||||
```bash
|
||||
gh label list --json name,description --limit 100
|
||||
gh pr edit '"${PR_NUMBER}"' --add-label '\''label1,label2'\''
|
||||
```
|
||||
|
||||
When done, add the reviewed label:
|
||||
```bash
|
||||
gh pr edit '"${PR_NUMBER}"' --add-label '\''Bot Reviewed'\''
|
||||
```
|
||||
|
||||
## Remember
|
||||
|
||||
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like '\''let me know if you have questions'\'', '\''I can help you with'\'', or '\''feel free to ask'\'' - you won'\''t be there to follow up. Frame any questions for the maintainers who will follow up.
|
||||
- Be helpful and welcoming - Many contributors are new to the project
|
||||
- Be specific - Point to exact lines and suggest fixes, don'\''t give vague feedback
|
||||
- Think deeply - Don'\''t just surface-level review, understand the intent and evaluate the approach
|
||||
- Use inline suggestions - Make it easy for authors to accept changes
|
||||
- You'\''re a first-pass reviewer - A human maintainer will do final approval'
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Publish NPM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm you want to publish to NPM'
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write # Required for pushing tags
|
||||
id-token: write # Required for npm trusted publishing (OIDC)
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
@@ -20,7 +21,7 @@ jobs:
|
||||
publish-npm-release:
|
||||
needs: test
|
||||
name: Publish Cline CLI to NPM
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && github.event.inputs.confirm_publish == 'publish'
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && inputs.confirm_publish == 'publish'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -30,19 +31,10 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies and CLI dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
|
||||
|
||||
- name: Generate Protos
|
||||
@@ -81,8 +73,6 @@ jobs:
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'latest'..."
|
||||
cd dist-standalone
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
name: Publish NPM Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
force_publish:
|
||||
description: "Force publish even if there are no commits in the last 24 hours"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for npm trusted publishing (OIDC)
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
@@ -27,6 +32,12 @@ jobs:
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
run: |
|
||||
if [ "${{ inputs.force_publish }}" = "true" ]; then
|
||||
echo "force_publish enabled, proceeding with publish"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
|
||||
echo "No commits in last 24 hours, skipping publish"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
@@ -39,18 +50,9 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies and CLI dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
|
||||
@@ -118,8 +120,6 @@ jobs:
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'nightly'..."
|
||||
cd dist-standalone
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Build and Pack CLI
|
||||
#
|
||||
# Builds a CLI tarball from any branch/commit and publishes it as a GitHub Release.
|
||||
# Requires write access to the repository (maintainers/collaborators only).
|
||||
#
|
||||
# Security: Split into two jobs to isolate untrusted build code from write tokens.
|
||||
# The build job runs arbitrary ref code with zero permissions. The release job
|
||||
# only runs trusted GitHub Actions with write scope.
|
||||
#
|
||||
# Usage (helper script, auto-detects current branch):
|
||||
# ./scripts/build-cli-artifact.sh
|
||||
# ./scripts/build-cli-artifact.sh feature/my-changes
|
||||
# ./scripts/build-cli-artifact.sh feature/my-changes 1234 # comments on PR
|
||||
#
|
||||
# Usage (gh CLI directly):
|
||||
# gh workflow run pack-cli.yml -f ref=main
|
||||
# gh workflow run pack-cli.yml -f ref=abc123 -f pr_number=1234
|
||||
#
|
||||
# Install the built CLI (no auth required):
|
||||
# npm install -g https://github.com/cline/cline/releases/download/cli-build-<sha>/cline-<ver>.tgz
|
||||
#
|
||||
# Find releases:
|
||||
# gh release list --limit 10
|
||||
|
||||
name: Build and Pack CLI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch, tag, or commit SHA to build (leave empty for default branch)'
|
||||
required: false
|
||||
type: string
|
||||
pr_number:
|
||||
description: 'PR number to comment on with install instructions (optional)'
|
||||
required: false
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
# ── Build job: runs untrusted ref code with ZERO permissions ──
|
||||
build:
|
||||
name: Build CLI
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
outputs:
|
||||
commit_sha: ${{ steps.commit.outputs.sha }}
|
||||
tarball: ${{ steps.pack.outputs.tarball }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get commit SHA
|
||||
id: commit
|
||||
run: |
|
||||
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Building from commit: $COMMIT_SHA"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Generate Protos
|
||||
run: npm run protos
|
||||
|
||||
- name: Build standalone package
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- name: Create Tarball
|
||||
id: pack
|
||||
run: |
|
||||
cd dist-standalone
|
||||
TARBALL=$(npm pack)
|
||||
echo "tarball=$TARBALL" >> $GITHUB_OUTPUT
|
||||
echo "Created tarball: $TARBALL"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-tarball
|
||||
path: dist-standalone/*.tgz
|
||||
|
||||
# ── Release job: only trusted Actions code, with write permissions ──
|
||||
release:
|
||||
name: Release CLI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cli-tarball
|
||||
path: dist-standalone
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const commit = '${{ needs.build.outputs.commit_sha }}';
|
||||
const tarball = '${{ needs.build.outputs.tarball }}';
|
||||
|
||||
// Delete existing release/tag if re-running for the same commit
|
||||
const tagName = `cli-build-${commit}`;
|
||||
try {
|
||||
const existing = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag: tagName
|
||||
});
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: existing.data.id
|
||||
});
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${tagName}`
|
||||
});
|
||||
core.info(`Deleted existing release for ${tagName}`);
|
||||
} catch (e) {
|
||||
// Release doesn't exist yet, that's fine
|
||||
}
|
||||
|
||||
// Create a release
|
||||
const release = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tagName,
|
||||
name: `CLI Build (${commit})`,
|
||||
body: `Automated CLI build from commit ${commit}\n\nInstall with:\n\`\`\`bash\nnpm install -g https://github.com/${context.repo.owner}/${context.repo.repo}/releases/download/${tagName}/${tarball}\n\`\`\``,
|
||||
draft: false,
|
||||
prerelease: true
|
||||
});
|
||||
|
||||
// Upload the tarball as a release asset
|
||||
const tarballPath = path.join('dist-standalone', tarball);
|
||||
const tarballData = fs.readFileSync(tarballPath);
|
||||
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.data.id,
|
||||
name: tarball,
|
||||
data: tarballData
|
||||
});
|
||||
|
||||
const downloadUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/releases/download/${tagName}/${tarball}`;
|
||||
core.setOutput('release_url', release.data.html_url);
|
||||
core.setOutput('download_url', downloadUrl);
|
||||
|
||||
- name: Comment on PR with download instructions
|
||||
if: inputs.pr_number != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const commit = '${{ needs.build.outputs.commit_sha }}';
|
||||
const releaseUrl = '${{ steps.create_release.outputs.release_url }}';
|
||||
const downloadUrl = '${{ steps.create_release.outputs.download_url }}';
|
||||
const prNumber = ${{ inputs.pr_number || 0 }};
|
||||
if (!prNumber) return;
|
||||
|
||||
const comment = `## 📦 CLI Build Ready
|
||||
|
||||
A CLI build has been created for commit \`${commit}\`.
|
||||
|
||||
### Install Directly from URL (No Authentication Required!)
|
||||
|
||||
\`\`\`bash
|
||||
npm install -g ${downloadUrl}
|
||||
\`\`\`
|
||||
|
||||
### Alternative: Download and Install
|
||||
|
||||
\`\`\`bash
|
||||
curl -L ${downloadUrl} -o cline.tgz
|
||||
npm install -g ./cline.tgz
|
||||
\`\`\`
|
||||
|
||||
📦 [View Release](${releaseUrl})
|
||||
`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: prNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✅ CLI build complete!"
|
||||
echo ""
|
||||
echo "📦 Release: ${{ steps.create_release.outputs.release_url }}"
|
||||
echo "🔗 Download URL: ${{ steps.create_release.outputs.download_url }}"
|
||||
echo ""
|
||||
echo "Install from anywhere (no authentication required):"
|
||||
echo " npm install -g ${{ steps.create_release.outputs.download_url }}"
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish CLI (Trusted)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_target:
|
||||
description: "Which publish flow to run"
|
||||
required: true
|
||||
default: "main"
|
||||
type: choice
|
||||
options:
|
||||
- main
|
||||
- nightly
|
||||
confirm_publish:
|
||||
description: 'Required when publish_target=main. Type "publish" to confirm release publish.'
|
||||
required: false
|
||||
type: string
|
||||
force_nightly_publish:
|
||||
description: "Force nightly publish even with no commits in last 24h"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for npm trusted publishing (OIDC)
|
||||
contents: write # Required because npm-main creates/pushes git tags
|
||||
checks: write # Required by nested reusable test workflow
|
||||
pull-requests: write # Required by nested reusable test workflow
|
||||
|
||||
jobs:
|
||||
publish-main:
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.publish_target == 'main' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
)
|
||||
uses: ./.github/workflows/npm-main.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
confirm_publish: ${{ github.event.inputs.confirm_publish }}
|
||||
|
||||
publish-nightly:
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_target == 'nightly')
|
||||
)
|
||||
uses: ./.github/workflows/npm-nightly.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
force_publish: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
|
||||
@@ -36,23 +36,9 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
@@ -42,30 +42,12 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
|
||||
@@ -48,3 +48,6 @@ test-results
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Smoke test results (generated)
|
||||
evals/smoke-tests/results/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "evals/cline-bench"]
|
||||
path = evals/cline-bench
|
||||
url = https://github.com/cline/cline-bench.git
|
||||
Vendored
+2
-1
@@ -16,7 +16,8 @@
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
"${workspaceFolder}",
|
||||
"--disable-extensions"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
|
||||
@@ -35,11 +35,9 @@ cli/**
|
||||
eslint-rules/
|
||||
old_docs/
|
||||
evals/
|
||||
.changie.yaml
|
||||
.codespellrc
|
||||
.mocharc.json
|
||||
buf.yaml
|
||||
.changeset/
|
||||
.clinerules/
|
||||
|
||||
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
|
||||
|
||||
+142
-12
@@ -1,5 +1,135 @@
|
||||
# Changelog
|
||||
|
||||
## [3.67.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cline SDK API interface for programmatic access to Cline features and tools, enabling integration into custom applications.
|
||||
- Added Codex 5.3 model support
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OpenAI Codex by setting `store` to `false`
|
||||
- Use `isLocatedInPath()` instead of string matching for path containment checks
|
||||
|
||||
## [3.67.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for skills and optional modelId in subagent configuration
|
||||
- Add AgentConfigLoader for file-based agent configs
|
||||
- Add Responses API support for OpenAI native provider
|
||||
- Preconnect websocket to reduce response latency
|
||||
- Fetch featured models from backend with local fallback
|
||||
- Add /q command to quit CLI
|
||||
- Add MCP enterprise configuration details
|
||||
- Pull Cline's recommended models from internal endpoint
|
||||
- Add dynamic flag to adjust banner cache duration
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix reasoning delta crash on usage-only stream chunks
|
||||
- Fix OpenAI tool ID transformation restricted to native provider only
|
||||
- Fix auth check for ACP mode
|
||||
- Fix CLI yolo mode to not persist yolo setting to disk
|
||||
- Fix inline focus-chain slider within its feature row
|
||||
- Fix Gemini 3.1 Pro compatibility
|
||||
- Fix Cline auth with ACP flag
|
||||
|
||||
### Changed
|
||||
|
||||
- Move PR skill to .agents/skills
|
||||
- SambaNova provider: update models list
|
||||
- Remove changeset-converter GitHub Action and npm run changeset
|
||||
|
||||
## [3.66.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
|
||||
## [3.65.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add /skills slash command to CLI for viewing and managing installed skills
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix aggressive context compaction caused by accidental clicks on the context window progress bar silently setting a very low auto-condense threshold
|
||||
- Fix infinite retry loop when write_to_file fails with missing content parameter.
|
||||
- Fixed default claude model
|
||||
|
||||
## [3.64.0]
|
||||
|
||||
### Added
|
||||
- Added sonnet 4.6
|
||||
|
||||
|
||||
## [3.63.0]
|
||||
|
||||
### Added
|
||||
|
||||
- added zai GLM 5 Free promo
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore reasoning trace visibility in chat and improve the thinking row UX so reasoning is visible, then collapsible after completion.
|
||||
|
||||
## [3.62.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Banners now display immediately when opening the extension instead of requiring user interaction first
|
||||
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
|
||||
|
||||
## [3.61.0]
|
||||
|
||||
- UI/UX fixes with minimax model family
|
||||
|
||||
## [3.60.0]
|
||||
|
||||
- Fixes for Minimax model family
|
||||
|
||||
## [3.59.0]
|
||||
|
||||
- Added Minimax 2.5 Free Promo
|
||||
- Fixed Response chaining for OpenAI's Responses API
|
||||
|
||||
## [3.58.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Subagent: replace legacy subagents with the native `use_subagents` tool
|
||||
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
|
||||
- Amazon Bedrock: support parallel tool calling
|
||||
- New "double-check completion" experimental feature to verify work before marking tasks complete
|
||||
- CLI: new task controls/flags including custom `--thinking` token budget and `--max-consecutive-mistakes` for yolo runs
|
||||
- Remote config: new UI/options (including connection/test buttons) and support for syncing deletion of remotely configured MCP servers
|
||||
- Vertex / Claude Code: add 1M context model options for Claude Opus 4.6
|
||||
- ZAI/GLM: add GLM-5
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI: handle stdin redirection correctly in CI/headless environments
|
||||
- CLI: preserve OAuth callback paths during auth redirects
|
||||
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
|
||||
- Terminal: surface command exit codes in results and improve long-running `execute_command` timeout behavior
|
||||
- UI: add loading indicator and fix `api_req_started` rendering
|
||||
- Task streaming: prevent duplicate streamed text rows after completion
|
||||
- API: preserve selected Vercel model when model metadata is missing
|
||||
- Telemetry: route PostHog networking through proxy-aware shared fetch and ensure telemetry flushes on shutdown
|
||||
- CI: increase Windows E2E test timeout to reduce flakiness
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
|
||||
- CLI provider selection: limit provider list to those remotely configured
|
||||
- UI: consolidate ViewHeader component/styling across views
|
||||
- Tools: add auto-approval support for `attempt_completion` commands
|
||||
- Remotely configured MCP server schema now supports custom headers
|
||||
|
||||
## [3.57.1]
|
||||
|
||||
### Fixed
|
||||
@@ -11,7 +141,7 @@
|
||||
### Added
|
||||
|
||||
- Cline CLI 2.0 now available. Install with `npm install -g cline`
|
||||
- Anthopic Opus 4.6
|
||||
- Anthopic Opus 4.6
|
||||
- Minimax-2.1 and Kimi-k2.5 now available for free for a limited time promo
|
||||
- Codex-5.3 through ChatGPT subscription
|
||||
|
||||
@@ -31,23 +161,23 @@
|
||||
|
||||
### Added
|
||||
|
||||
- __CLI authentication:__ Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
|
||||
- __New model:__ Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
|
||||
- __Prompt variant:__ Added Trinity Large prompt variant for improved tool-calling support
|
||||
- __OpenTelemetry:__ Added support for custom headers on metrics and logs endpoints
|
||||
- __Social links:__ Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
|
||||
- **CLI authentication:** Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
|
||||
- **New model:** Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
|
||||
- **Prompt variant:** Added Trinity Large prompt variant for improved tool-calling support
|
||||
- **OpenTelemetry:** Added support for custom headers on metrics and logs endpoints
|
||||
- **Social links:** Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
|
||||
|
||||
### Fixed
|
||||
|
||||
- __LiteLLM:__ Fixed thinking configuration not appearing for reasoning-capable models
|
||||
- __OpenTelemetry:__ Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
|
||||
- __CLI auth:__ Fixed `cline auth` displaying incorrect provider information after configuration
|
||||
- **LiteLLM:** Fixed thinking configuration not appearing for reasoning-capable models
|
||||
- **OpenTelemetry:** Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
|
||||
- **CLI auth:** Fixed `cline auth` displaying incorrect provider information after configuration
|
||||
|
||||
### Changed
|
||||
|
||||
- __Hooks:__ Hook scripts now run from the workspace repository root instead of filesystem root
|
||||
- __Default settings:__ Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
|
||||
- __Settings UI:__ Refreshed feature settings section with collapsible design
|
||||
- **Hooks:** Hook scripts now run from the workspace repository root instead of filesystem root
|
||||
- **Default settings:** Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
|
||||
- **Settings UI:** Refreshed feature settings section with collapsible design
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
|
||||
+6
-25
@@ -57,25 +57,11 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
|
||||
### Creating a Pull Request
|
||||
|
||||
1. Before creating a PR, generate a changeset entry:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
This will prompt you for:
|
||||
- Type of change (major, minor, patch)
|
||||
- `major` → breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` → new features (1.0.0 → 1.1.0)
|
||||
- `patch` → bug fixes (1.0.0 → 1.0.1)
|
||||
- Description of your changes
|
||||
1. Commit your changes.
|
||||
|
||||
2. Commit your changes and the generated `.changeset` file
|
||||
|
||||
3. Push your branch and create a PR on GitHub. Our CI will:
|
||||
2. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
- Changesetbot will create a comment showing the version impact
|
||||
- When merged to main, changesetbot will create a Version Packages PR
|
||||
- When the Version Packages PR is merged, a new release will be published
|
||||
4. Testing
|
||||
3. Testing
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
@@ -192,15 +178,10 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
- Temporary workspaces with test fixtures
|
||||
- Video recording for failed tests
|
||||
|
||||
4. **Version Management with Changesets**
|
||||
4. **Versioning & Changelog Notes**
|
||||
|
||||
- Create a changeset for any user-facing changes using `npm run changeset`
|
||||
- Choose the appropriate version bump:
|
||||
- `major` for breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` for new features (1.0.0 → 1.1.0)
|
||||
- `patch` for bug fixes (1.0.0 → 1.0.1)
|
||||
- Write clear, descriptive changeset messages that explain the impact
|
||||
- Documentation-only changes don't require changesets
|
||||
- Contributors do not need to create changelog-entry files as part of PRs.
|
||||
- Maintainers handle release versioning and changelog curation during the release process.
|
||||
|
||||
5. **Commit Guidelines**
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/cline/cline/security/advisories/new) tab.
|
||||
|
||||
The team will send a response indicating the next steps in handling your report. After the initial reply, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
- A short summary of the issue
|
||||
- Steps to reproduce or a proof of concept
|
||||
- Any logs, stack traces, or screenshots that might help us understand the problem
|
||||
|
||||
We acknowledge reports within 48 hours and aim to release a fix or mitigation within 30 days. While we work on a resolution, please keep the details private.
|
||||
|
||||
## Escalation
|
||||
|
||||
If you do not receive an acknowledgement of your report within 5 business days, you may send an email to security@cline.bot.
|
||||
|
||||
Thank you for helping us keep Cline users safe.
|
||||
@@ -0,0 +1,130 @@
|
||||
# cline
|
||||
|
||||
## [2.5.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cline SDK API interface for programmatic access to Cline features and tools, enabling integration into custom applications.
|
||||
- Added Codex 5.3 model support
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OpenAI Codex by setting `store` to `false`
|
||||
- Use `isLocatedInPath()` instead of string matching for path containment checks
|
||||
|
||||
## [2.4.3]
|
||||
|
||||
### Added
|
||||
|
||||
- Add /q command to quit CLI
|
||||
- Fetch featured models from backend with local fallback
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix auth check for ACP mode
|
||||
- Fix Cline auth with ACP flag
|
||||
- Fix yolo mode to not persist yolo setting to disk
|
||||
|
||||
## [2.4.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- VSCode uses shared files for global, workspace and secret state.
|
||||
|
||||
## [2.4.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop.
|
||||
|
||||
## [2.4.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding Anthropic Sonnet 4.6
|
||||
- Allows users to enter custom aws region when selecting bedrock as a provider in CLI
|
||||
- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning.
|
||||
- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Banners now display immediately when opening the extension instead of requiring user interaction first
|
||||
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
|
||||
|
||||
## [2.2.2]
|
||||
|
||||
- Allows users to enter custom aws region when selecting bedrock as a provider
|
||||
- Prevent Parent Container Scrolling In Dropdowns
|
||||
|
||||
## [2.2.1]
|
||||
|
||||
- Added Minimax 2.5 Free Promo
|
||||
- Fixed Response chaining for OpenAI's Responses API
|
||||
|
||||
## [2.2.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Subagent: replace legacy subagents with the native `use_subagents` tool
|
||||
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
|
||||
- Amazon Bedrock: support parallel tool calling
|
||||
- New "double-check completion" experimental feature to verify work before marking tasks complete
|
||||
- CLI: new task controls/flags including custom `--thinking` token budget and `--max-consecutive-mistakes` for yolo runs
|
||||
- Remote config: new UI/options (including connection/test buttons) and support for syncing deletion of remotely configured MCP servers
|
||||
- Vertex / Claude Code: add 1M context model options for Claude Opus 4.6
|
||||
- ZAI/GLM: add GLM-5
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI: handle stdin redirection correctly in CI/headless environments
|
||||
- CLI: preserve OAuth callback paths during auth redirects
|
||||
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
|
||||
- Terminal: surface command exit codes in results and improve long-running `execute_command` timeout behavior
|
||||
- UI: add loading indicator and fix `api_req_started` rendering
|
||||
- Task streaming: prevent duplicate streamed text rows after completion
|
||||
- API: preserve selected Vercel model when model metadata is missing
|
||||
- Telemetry: route PostHog networking through proxy-aware shared fetch and ensure telemetry flushes on shutdown
|
||||
- CI: increase Windows E2E test timeout to reduce flakiness
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
|
||||
- CLI provider selection: limit provider list to those remotely configured
|
||||
- UI: consolidate ViewHeader component/styling across views
|
||||
- Tools: add auto-approval support for `attempt_completion` commands
|
||||
- Remotely configured MCP server schema now supports custom headers
|
||||
|
||||
## [2.1.0]
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 42ce100: Add Generate API Key on Hicap Provider selection
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 195294f: Add support for bundled endpoints.json in enterprise distributions. Extensions can now include a pre-configured endpoints.json file that automatically switches Cline to self-hosted mode. Includes packaging scripts for VSIX, NPM, and JetBrains plugins.
|
||||
- a1f2601: Replace the LiteLLM model list with a selector
|
||||
- 739d75a: Add Claude Code provider support for Claude Opus 4.6 and Sonnet 4.5 1M variants via both full model names and aliases (`opus[1m]`, `sonnet[1m]`), and align the `opus` alias with Opus 4.6.
|
||||
- 8440380: Add GitHub Actions workflow to build CLI from any commit for testing
|
||||
- b1a8db2: fix(cli): prevent hang when spawned without TTY
|
||||
- 7c87017: Add Claude Opus 4.6 model support
|
||||
- d116ac5: Supports rendering markdown table in chat view.
|
||||
- 6d8fb85: Fix CLI crashing in CI environments and with stdin redirection (e.g., `cline "prompt" < /dev/null`). Now checks both stdin and stdout TTY status before using Ink, and only errors on empty stdin when no prompt is provided.
|
||||
- 70a9904: Fix JetBrains sign-in regression by adding fallback for openExternal RPC
|
||||
- f440f3a: fix: use vscode.env.openExternal for auth in remote environments
|
||||
|
||||
Fixes OAuth authentication in VS Code Server and remote environments by routing browser URL opening through VS Code's native openExternal API instead of the npm 'open' package.
|
||||
|
||||
- 70a9904: fix: use vscode.env.asExternalUri for auth callback URLs only in VS Code Web
|
||||
|
||||
Fixes OAuth callback redirect in VS Code Web (`code serve-web`, Codespaces) by using `vscode.env.asExternalUri()` to resolve the callback URI. This is gated behind a `vscode.env.uiKind === UIKind.Web` check so regular desktop VS Code continues to use the `vscode://` URI directly. The `getCallbackUrl` API now accepts a `path` parameter so the full callback URI (including route) is resolved correctly, and callers pass their path directly instead of appending after.
|
||||
|
||||
- 5308ded: Updating script documentation and removing unnecessary continue on error
|
||||
- b514f18: Prevent duplicate streamed text rows when a partial text update arrives after the same text was already finalized.
|
||||
- 26391c9: Fix Bedrock model id
|
||||
- d19a877: Unify ViewHeader Styles Across All Views
|
||||
- 5dcaa8c: Add Vertex Claude Opus 4.6 1M model option and global endpoint support, and pass the 1M beta header for Vertex Claude requests.
|
||||
+1
-2
@@ -45,7 +45,7 @@ cline
|
||||
|
||||
### Use any API and Model
|
||||
|
||||
Cline supports API providers like ChatGPT, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, Cerebras and Groq. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using a Cline Account, you'll always have access to the newest models as soon as they're available.
|
||||
Cline supports API providers like ChatGPT, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, Cerebras, Groq, and Moonshot. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using a Cline Account, you'll always have access to the newest models as soon as they're available.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
@@ -79,4 +79,3 @@ Get the same Cline experience with enterprise-grade controls: SSO (SAML/OIDC), g
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2026 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
+41
-10
@@ -208,8 +208,8 @@ if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
const config: esbuild.BuildOptions = {
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
// Shared build options
|
||||
const sharedOptions: Partial<esbuild.BuildOptions> = {
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
@@ -221,7 +221,6 @@ const config: esbuild.BuildOptions = {
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
// These modules need to load files from the module directory at runtime
|
||||
external: [
|
||||
"@grpc/reflection",
|
||||
@@ -237,6 +236,13 @@ const config: esbuild.BuildOptions = {
|
||||
"@vscode/ripgrep", // Uses __dirname to locate the binary
|
||||
],
|
||||
supported: { "top-level-await": true },
|
||||
}
|
||||
|
||||
// CLI executable configuration
|
||||
const cliConfig: esbuild.BuildOptions = {
|
||||
...sharedOptions,
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
// Suppress all Node.js warnings (deprecation, experimental, etc.)
|
||||
@@ -250,19 +256,44 @@ const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
// Library configuration for programmatic use
|
||||
const libConfig: esbuild.BuildOptions = {
|
||||
...sharedOptions,
|
||||
entryPoints: [path.join(__dirname, "src", "exports.ts")],
|
||||
outfile: path.join(__dirname, "dist", "lib.mjs"),
|
||||
banner: {
|
||||
js: `// Cline Library - Programmatic API
|
||||
import { createRequire as _createRequire } from 'module';
|
||||
import { fileURLToPath as _fileURLToPath } from 'url';
|
||||
import { dirname as _dirname } from 'path';
|
||||
const require = _createRequire(import.meta.url);
|
||||
const __filename = _fileURLToPath(import.meta.url);
|
||||
const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context(config)
|
||||
if (watch) {
|
||||
// In watch mode, only watch the CLI (primary use case for development)
|
||||
const ctx = await esbuild.context(cliConfig)
|
||||
await ctx.watch()
|
||||
console.log("[cli] Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await ctx.dispose()
|
||||
// Build both CLI and library
|
||||
console.log("[cli esbuild] Building CLI executable...")
|
||||
const cliCtx = await esbuild.context(cliConfig)
|
||||
await cliCtx.rebuild()
|
||||
await cliCtx.dispose()
|
||||
|
||||
// Make the output executable
|
||||
const outfile = path.join(__dirname, "dist", "cli.mjs")
|
||||
if (fs.existsSync(outfile)) {
|
||||
fs.chmodSync(outfile, "755")
|
||||
console.log("[cli esbuild] Building library bundle...")
|
||||
const libCtx = await esbuild.context(libConfig)
|
||||
await libCtx.rebuild()
|
||||
await libCtx.dispose()
|
||||
|
||||
// Make the CLI output executable
|
||||
const cliOutfile = path.join(__dirname, "dist", "cli.mjs")
|
||||
if (fs.existsSync(cliOutfile)) {
|
||||
fs.chmodSync(cliOutfile, "755")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -125,13 +125,13 @@ authentication wizard, or use quick setup flags.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-provider\f[R] \f[I]id\f[R] : Provider ID for
|
||||
quick setup (e.g., openai\-native, anthropic, openrouter)
|
||||
quick setup (e.g., openai\-native, anthropic, openrouter, moonshot)
|
||||
.PP
|
||||
\f[B]\-k\f[R], \f[B]\-\-apikey\f[R] \f[I]key\f[R] : API key for the
|
||||
provider
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-modelid\f[R] \f[I]id\f[R] : Model ID to
|
||||
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929)
|
||||
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929, kimi\-k2.5)
|
||||
.PP
|
||||
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
|
||||
for OpenAI\-compatible providers)
|
||||
@@ -242,6 +242,9 @@ cline \-m claude\-sonnet\-4\-5\-20250929 \(dqRefactor this function\(dq
|
||||
|
||||
\f[I]# Quick auth setup with model\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-xxxxx \-m claude\-sonnet\-4\-5\-20250929
|
||||
|
||||
\f[I]# Quick auth setup for Moonshot\f[R]
|
||||
cline auth \-p moonshot \-k sk\-xxxxx \-m kimi\-k2.5
|
||||
.EE
|
||||
.SS Including Images
|
||||
.IP
|
||||
@@ -309,6 +312,9 @@ cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
|
||||
\f[I]# Quick setup for OpenAI\f[R]
|
||||
cline auth \-p openai\-native \-k sk\-xxxxx \-m gpt\-4o
|
||||
|
||||
\f[I]# Quick setup for Moonshot\f[R]
|
||||
cline auth \-p moonshot \-k sk\-xxxxx \-m kimi\-k2.5
|
||||
|
||||
\f[I]# OpenAI\-compatible provider with custom base URL\f[R]
|
||||
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
|
||||
.EE
|
||||
|
||||
@@ -56,6 +56,8 @@ Run a new task with a prompt.
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
@@ -144,6 +146,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
+12
-4
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.0.5",
|
||||
"version": "2.5.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/cli.mjs",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
"bin": {
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/lib.mjs",
|
||||
"types": "./dist/lib.d.ts"
|
||||
}
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
@@ -23,8 +30,9 @@
|
||||
"scripts": {
|
||||
"package:brew": "npx tsx ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "npm run typecheck && npx tsx esbuild.mts",
|
||||
"build:production": "npm run typecheck && npx tsx esbuild.mts --production",
|
||||
"build": "npm run typecheck && npx tsx esbuild.mts && npm run build:types",
|
||||
"build:production": "npm run typecheck && npx tsx esbuild.mts --production && npm run build:types",
|
||||
"build:types": "(npx tsc -p tsconfig.lib.json || true) && cp dist/types/cli/src/exports.d.ts dist/lib.d.ts && mkdir -p dist/agent && cp dist/types/cli/src/agent/ClineAgent.d.ts dist/types/cli/src/agent/ClineSessionEmitter.d.ts dist/types/cli/src/agent/public-types.d.ts dist/agent/ && rm -rf dist/types",
|
||||
"watch": "npx tsx esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
|
||||
@@ -108,11 +108,7 @@ class ACPDiffServiceClient implements DiffServiceClientInterface {
|
||||
class ACPEnvServiceClient implements EnvServiceClientInterface {
|
||||
private readonly version: string
|
||||
|
||||
constructor(
|
||||
_clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
_sessionIdResolver: SessionIdResolver,
|
||||
version: string = "1.0.0",
|
||||
) {
|
||||
constructor(_clientCapabilities: acp.ClientCapabilities | undefined, _sessionIdResolver: SessionIdResolver, version: string) {
|
||||
this.version = version
|
||||
}
|
||||
|
||||
@@ -402,7 +398,7 @@ export class ACPHostBridgeClientProvider implements HostBridgeClientProvider {
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
sessionIdResolver: SessionIdResolver,
|
||||
cwdResolver: CwdResolver,
|
||||
version: string = "1.0.0",
|
||||
version: string,
|
||||
) {
|
||||
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
|
||||
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
|
||||
|
||||
+5
-21
@@ -15,7 +15,7 @@
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import { ClineAgent } from "../agent/ClineAgent.js"
|
||||
import type { AcpAgentOptions, SessionUpdateType } from "../agent/types.js"
|
||||
import { type AcpAgentOptions, type SessionUpdateType } from "../agent/types.js"
|
||||
|
||||
/**
|
||||
* ACP Agent wrapper that bridges stdio connection to ClineAgent.
|
||||
@@ -39,37 +39,21 @@ export class AcpAgent implements acp.Agent {
|
||||
this.clineAgent = new ClineAgent(options)
|
||||
|
||||
// Wire up the permission handler to use the connection
|
||||
this.clineAgent.setPermissionHandler(async (request, resolve) => {
|
||||
this.clineAgent.setPermissionHandler(async (request) => {
|
||||
try {
|
||||
Logger.debug("[AcpAgent] Forwarding permission request to connection")
|
||||
const response = await this.connection.requestPermission({
|
||||
sessionId: this.getCurrentSessionId() ?? "",
|
||||
return await this.connection.requestPermission({
|
||||
sessionId: request.sessionId,
|
||||
toolCall: request.toolCall,
|
||||
options: request.options,
|
||||
})
|
||||
resolve(response)
|
||||
} catch (error) {
|
||||
Logger.debug("[AcpAgent] Error requesting permission:", error)
|
||||
resolve({ outcome: "rejected" as unknown as acp.RequestPermissionOutcome })
|
||||
return { outcome: { outcome: "cancelled" } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active session ID from the ClineAgent.
|
||||
*/
|
||||
private getCurrentSessionId(): string | undefined {
|
||||
// Find the session that's currently processing
|
||||
for (const [sessionId, session] of this.clineAgent.sessions) {
|
||||
if (session.controller?.task) {
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
// Fall back to the first session if none is actively processing
|
||||
const firstSession = this.clineAgent.sessions.keys().next()
|
||||
return firstSession.done ? undefined : firstSession.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to session events and forward them to the connection.
|
||||
*/
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { TerminalHandle } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT,
|
||||
DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT,
|
||||
PROCESS_HOT_TIMEOUT_NORMAL,
|
||||
} from "@integrations/terminal/constants"
|
||||
import { DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT, PROCESS_HOT_TIMEOUT_NORMAL } from "@integrations/terminal/constants"
|
||||
import type {
|
||||
ITerminal,
|
||||
ITerminalManager,
|
||||
@@ -142,12 +138,12 @@ export interface ManagedTerminal {
|
||||
* Wraps ACP terminal operations and emits events compatible with ITerminalProcess.
|
||||
*/
|
||||
class AcpTerminalProcess extends EventEmitter<TerminalProcessEvents> implements ITerminalProcess {
|
||||
isHot: boolean = false
|
||||
waitForShellIntegration: boolean = false
|
||||
isHot = false
|
||||
waitForShellIntegration = false
|
||||
|
||||
private _unretrievedOutput: string = ""
|
||||
private _continued: boolean = false
|
||||
private _completed: boolean = false
|
||||
private _unretrievedOutput = ""
|
||||
private _continued = false
|
||||
private _completed = false
|
||||
private _hotTimeout: NodeJS.Timeout | null = null
|
||||
private _exitWaitTimeout: NodeJS.Timeout | null = null
|
||||
private readonly manager: AcpTerminalManager
|
||||
@@ -397,7 +393,7 @@ export class AcpTerminalManager implements ITerminalManager {
|
||||
private readonly numericIdToStringId: Map<number, string> = new Map()
|
||||
|
||||
/** Next numeric ID to assign */
|
||||
private nextNumericId: number = 1
|
||||
private nextNumericId = 1
|
||||
|
||||
/** Active processes indexed by numeric terminal ID */
|
||||
private readonly processes: Map<number, AcpTerminalProcess> = new Map()
|
||||
@@ -406,9 +402,8 @@ export class AcpTerminalManager implements ITerminalManager {
|
||||
private readonly terminalInfos: Map<number, TerminalInfo> = new Map()
|
||||
|
||||
// Configuration options for ITerminalManager
|
||||
private terminalReuseEnabled: boolean = true
|
||||
private terminalReuseEnabled = true
|
||||
private terminalOutputLineLimit: number = DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT
|
||||
private subagentTerminalOutputLineLimit: number = DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT
|
||||
|
||||
/**
|
||||
* Creates a new AcpTerminalManager.
|
||||
@@ -667,14 +662,6 @@ export class AcpTerminalManager implements ITerminalManager {
|
||||
this.terminalOutputLineLimit = limit
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum number of output lines for subagent commands.
|
||||
* @param limit Maximum number of lines
|
||||
*/
|
||||
setSubagentTerminalOutputLineLimit(limit: number): void {
|
||||
this.subagentTerminalOutputLineLimit = limit
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default terminal profile.
|
||||
* @param profile The profile identifier
|
||||
@@ -687,15 +674,10 @@ export class AcpTerminalManager implements ITerminalManager {
|
||||
* Process output lines, potentially truncating if over limit.
|
||||
* @param outputLines Array of output lines
|
||||
* @param overrideLimit Optional limit override
|
||||
* @param isSubagentCommand Whether this is a subagent command
|
||||
* @returns Processed output string
|
||||
*/
|
||||
processOutput(outputLines: string[], overrideLimit?: number, isSubagentCommand?: boolean): string {
|
||||
const limit = isSubagentCommand
|
||||
? overrideLimit !== undefined
|
||||
? overrideLimit
|
||||
: this.subagentTerminalOutputLineLimit
|
||||
: this.terminalOutputLineLimit
|
||||
processOutput(outputLines: string[], overrideLimit?: number): string {
|
||||
const limit = overrideLimit !== undefined ? overrideLimit : this.terminalOutputLineLimit
|
||||
|
||||
if (outputLines.length > limit) {
|
||||
const halfLimit = Math.floor(limit / 2)
|
||||
|
||||
@@ -15,22 +15,18 @@
|
||||
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { version as CLI_VERSION } from "../../../package.json"
|
||||
import { AcpAgent } from "./AcpAgent.js"
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./streamUtils.js"
|
||||
|
||||
// Re-export classes for programmatic use
|
||||
export { ClineAgent } from "../agent/ClineAgent.js"
|
||||
export { ClineSessionEmitter } from "../agent/ClineSessionEmitter.js"
|
||||
// Re-export types
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
ClineAcpSession,
|
||||
ClineAgentOptions,
|
||||
ClineSessionEvents,
|
||||
PermissionHandler,
|
||||
PermissionResolver,
|
||||
} from "../agent/types.js"
|
||||
export { AcpAgent } from "./AcpAgent.js"
|
||||
|
||||
@@ -99,7 +95,6 @@ export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
agent = new AcpAgent(conn, {
|
||||
version: CLI_VERSION,
|
||||
debug: Boolean(options.verbose),
|
||||
})
|
||||
return agent
|
||||
|
||||
+58
-85
@@ -28,6 +28,8 @@ import {
|
||||
groqModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
moonshotDefaultModelId,
|
||||
moonshotModels,
|
||||
openAiCodexDefaultModelId,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
@@ -36,7 +38,6 @@ import {
|
||||
} from "@shared/api"
|
||||
import type { ClineAsk, ClineMessage as ClineMessageType } from "@shared/ExtensionMessage"
|
||||
import { CLI_ONLY_COMMANDS, VSCODE_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import { ClineEndpoint } from "@/config.js"
|
||||
import { Controller } from "@/core/controller"
|
||||
@@ -51,18 +52,21 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/index.js"
|
||||
import { AuthService } from "@/services/auth/AuthService.js"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import type { Mode } from "@/shared/storage/types"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { version as AGENT_VERSION } from "../../package.json"
|
||||
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
|
||||
import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js"
|
||||
import { AcpTerminalManager } from "../acp/AcpTerminalManager.js"
|
||||
import { isAuthConfigured } from "../utils/auth"
|
||||
import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { CliContextResult, initializeCliContext } from "../vscode-context.js"
|
||||
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
|
||||
import { translateMessage } from "./messageTranslator.js"
|
||||
import { handlePermissionResponse } from "./permissionHandler.js"
|
||||
import type { AcpSessionState, ClineAcpSession, ClineAgentOptions, PermissionHandler } from "./types.js"
|
||||
import type { ClineAcpSession, ClineAgentOptions, PermissionHandler } from "./public-types.js"
|
||||
import { AcpSessionStatus } from "./public-types.js"
|
||||
import { type AcpSessionState } from "./types.js"
|
||||
|
||||
// Map providers to their static model lists and defaults (copied from ModelPicker.tsx)
|
||||
const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
||||
@@ -72,6 +76,7 @@ const providerModels: Record<string, { models: Record<string, unknown>; defaultI
|
||||
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
|
||||
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
|
||||
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
|
||||
moonshot: { models: moonshotModels, defaultId: moonshotDefaultModelId },
|
||||
groq: { models: groqModels, defaultId: groqDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
}
|
||||
@@ -102,7 +107,12 @@ function getModelList(provider: string): string[] {
|
||||
export class ClineAgent implements acp.Agent {
|
||||
private readonly options: ClineAgentOptions
|
||||
private readonly ctx: CliContextResult
|
||||
readonly sessions: Map<string, ClineAcpSession> = new Map()
|
||||
|
||||
/** Map of active sessions by session ID */
|
||||
public readonly sessions: Map<string, ClineAcpSession> = new Map()
|
||||
|
||||
/** WeakMap to associate ClineAcpSession with its Controller without exposing it to consumers */
|
||||
readonly #sessionControllers = new WeakMap<ClineAcpSession, Controller>()
|
||||
|
||||
/** Runtime state for active sessions */
|
||||
private readonly sessionStates: Map<string, AcpSessionState> = new Map()
|
||||
@@ -130,7 +140,7 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
constructor(options: ClineAgentOptions) {
|
||||
this.options = options
|
||||
this.ctx = initializeCliContext()
|
||||
this.ctx = initializeCliContext({ clineDir: options.clineDir })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,8 +183,8 @@ export class ClineAgent implements acp.Agent {
|
||||
async initialize(params: acp.InitializeRequest, connection?: acp.AgentSideConnection): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = params.clientCapabilities
|
||||
this.initializeHostProvider(this.clientCapabilities, connection)
|
||||
await ClineEndpoint.initialize()
|
||||
await StateManager.initialize(this.ctx.extensionContext)
|
||||
await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
|
||||
await StateManager.initialize(this.ctx.storageContext)
|
||||
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
@@ -192,7 +202,7 @@ export class ClineAgent implements acp.Agent {
|
||||
},
|
||||
agentInfo: {
|
||||
name: "cline",
|
||||
version: this.options.version,
|
||||
version: AGENT_VERSION,
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
@@ -224,7 +234,7 @@ export class ClineAgent implements acp.Agent {
|
||||
clientCapabilities,
|
||||
() => this.currentActiveSessionId,
|
||||
() => this.sessions.get(this.currentActiveSessionId ?? "")?.cwd ?? process.cwd(),
|
||||
this.options.version,
|
||||
AGENT_VERSION,
|
||||
)
|
||||
|
||||
HostProvider.initialize(
|
||||
@@ -246,8 +256,8 @@ export class ClineAgent implements acp.Agent {
|
||||
},
|
||||
hostBridgeClientProvider,
|
||||
(message: string) => Logger.info(message),
|
||||
async () => {
|
||||
return AuthHandler.getInstance().getCallbackUrl()
|
||||
async (path: string) => {
|
||||
return AuthHandler.getInstance().getCallbackUrl(path)
|
||||
},
|
||||
async () => "", // get binary location not needed in ACP mode
|
||||
this.ctx.EXTENSION_DIR,
|
||||
@@ -263,7 +273,7 @@ export class ClineAgent implements acp.Agent {
|
||||
*/
|
||||
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
// Check if authentication is required
|
||||
const isAuthenticated = await this.isAuthConfigured()
|
||||
const isAuthenticated = await isAuthConfigured()
|
||||
if (!isAuthenticated) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
@@ -287,16 +297,16 @@ export class ClineAgent implements acp.Agent {
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
createdAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
controller,
|
||||
}
|
||||
|
||||
this.#sessionControllers.set(session, controller)
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// Initialize session state
|
||||
const sessionState: AcpSessionState = {
|
||||
sessionId,
|
||||
isProcessing: false,
|
||||
cancelled: false,
|
||||
status: AcpSessionStatus.Idle,
|
||||
pendingToolCalls: new Map(),
|
||||
}
|
||||
|
||||
@@ -433,11 +443,11 @@ export class ClineAgent implements acp.Agent {
|
||||
*
|
||||
* The prompt flow:
|
||||
* 1. Extract content from the ACP prompt (text, images, files)
|
||||
* 2. Set up state broadcasting (subscribe to controller updates)
|
||||
* 3. Initialize or continue task with Controller
|
||||
* 2. Set up internal cline state subsription
|
||||
* 3. Initialize or continue cline task
|
||||
* 4. Translate ClineMessages to ACP SessionUpdates
|
||||
* 5. Handle permission requests for tools/commands
|
||||
* 6. Return when task completes, is cancelled, or needs user input
|
||||
* 6. Return when cline task completes, is cancelled, or needs user input
|
||||
*/
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
@@ -447,11 +457,11 @@ export class ClineAgent implements acp.Agent {
|
||||
throw new Error(`Session not found: ${params.sessionId}`)
|
||||
}
|
||||
|
||||
if (sessionState.isProcessing) {
|
||||
if (sessionState.status === AcpSessionStatus.Processing) {
|
||||
throw new Error(`Session ${params.sessionId} is already processing a prompt`)
|
||||
}
|
||||
|
||||
const controller = session.controller
|
||||
const controller = this.#sessionControllers.get(session)
|
||||
if (!controller) {
|
||||
throw new Error("Controller not initialized for session. This is a bug in the ACP agent setup.")
|
||||
}
|
||||
@@ -462,8 +472,7 @@ export class ClineAgent implements acp.Agent {
|
||||
})
|
||||
|
||||
// Mark session as processing and set as current active session
|
||||
sessionState.isProcessing = true
|
||||
sessionState.cancelled = false
|
||||
sessionState.status = AcpSessionStatus.Processing
|
||||
session.lastActivityAt = Date.now()
|
||||
this.currentActiveSessionId = params.sessionId
|
||||
|
||||
@@ -584,7 +593,7 @@ export class ClineAgent implements acp.Agent {
|
||||
Logger.debug("[ClineAgent] Error during cleanup:", error)
|
||||
}
|
||||
}
|
||||
sessionState.isProcessing = false
|
||||
sessionState.status = AcpSessionStatus.Idle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,7 +655,13 @@ export class ClineAgent implements acp.Agent {
|
||||
permissionRequest: Omit<acp.RequestPermissionRequest, "sessionId">,
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
const controller = session?.controller
|
||||
|
||||
if (!session) {
|
||||
Logger.debug("[ClineAgent] No session found for permission request")
|
||||
return
|
||||
}
|
||||
|
||||
const controller = this.#sessionControllers.get(session)
|
||||
|
||||
if (!controller?.task) {
|
||||
Logger.debug("[ClineAgent] No active task for permission request")
|
||||
@@ -827,7 +842,7 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
await this.emitSessionUpdate(sessionId, {
|
||||
sessionUpdate,
|
||||
content: { type: "text", text: needsNewline ? "\n" + textDelta : textDelta },
|
||||
content: { type: "text", text: needsNewline ? `\n${textDelta}` : textDelta },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -880,18 +895,22 @@ export class ClineAgent implements acp.Agent {
|
||||
*/
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
Logger.debug("[ClineAgent] cancel called for non-existent session:", params.sessionId)
|
||||
return
|
||||
}
|
||||
const sessionState = this.sessionStates.get(params.sessionId)
|
||||
|
||||
Logger.debug("[ClineAgent] cancel called:", {
|
||||
sessionId: params.sessionId,
|
||||
isProcessing: sessionState?.isProcessing,
|
||||
status: sessionState?.status,
|
||||
})
|
||||
|
||||
if (sessionState) {
|
||||
sessionState.cancelled = true
|
||||
sessionState.status = AcpSessionStatus.Cancelled
|
||||
|
||||
// If we have an active controller task, cancel it
|
||||
const controller = session?.controller
|
||||
const controller = this.#sessionControllers.get(session)
|
||||
if (controller?.task) {
|
||||
try {
|
||||
await controller.cancelTask()
|
||||
@@ -932,7 +951,7 @@ export class ClineAgent implements acp.Agent {
|
||||
session.lastActivityAt = Date.now()
|
||||
|
||||
// Update Controller mode if active
|
||||
const controller = session.controller
|
||||
const controller = this.#sessionControllers.get(session)
|
||||
if (controller) {
|
||||
controller.stateManager.setGlobalState("mode", session.mode)
|
||||
|
||||
@@ -973,7 +992,7 @@ export class ClineAgent implements acp.Agent {
|
||||
// Get the callback URL first to ensure the server is ready
|
||||
let callbackUrl: string
|
||||
try {
|
||||
callbackUrl = await authHandler.getCallbackUrl()
|
||||
callbackUrl = await authHandler.getCallbackUrl("/auth")
|
||||
Logger.debug("[ClineAgent] Callback URL ready:", callbackUrl)
|
||||
} catch (error) {
|
||||
Logger.error("[ClineAgent] Failed to get callback URL:", error)
|
||||
@@ -1004,13 +1023,14 @@ export class ClineAgent implements acp.Agent {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check if auth data has been stored
|
||||
const authData = await secretStorage.get("cline:clineAccountId")
|
||||
const authData = stateManager.getSecretKey("cline:clineAccountId")
|
||||
if (authData) {
|
||||
Logger.debug("[ClineAgent] Authentication successful")
|
||||
|
||||
// Set up the provider configuration for cline
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("actModeApiProvider", "cline")
|
||||
stateManager.setGlobalState("planModeApiProvider", "cline")
|
||||
await stateManager.flushPendingState()
|
||||
@@ -1062,7 +1082,7 @@ export class ClineAgent implements acp.Agent {
|
||||
* @returns The permission response from the client
|
||||
*/
|
||||
protected async requestPermission(
|
||||
_sessionId: string,
|
||||
sessionId: string,
|
||||
toolCall: acp.ToolCallUpdate,
|
||||
options: acp.PermissionOption[],
|
||||
): Promise<acp.RequestPermissionResponse> {
|
||||
@@ -1077,17 +1097,15 @@ export class ClineAgent implements acp.Agent {
|
||||
return { outcome: "rejected" as unknown as acp.RequestPermissionOutcome }
|
||||
}
|
||||
|
||||
// Use the permission handler callback pattern
|
||||
return new Promise<acp.RequestPermissionResponse>((resolve) => {
|
||||
this.permissionHandler!({ toolCall, options }, resolve)
|
||||
})
|
||||
return await this.permissionHandler({ sessionId, toolCall, options })
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
await session.controller?.task?.abortTask()
|
||||
await session.controller?.stateManager.flushPendingState()
|
||||
await session.controller?.dispose()
|
||||
const controller = this.#sessionControllers.get(session)
|
||||
await controller?.task?.abortTask()
|
||||
await controller?.stateManager.flushPendingState()
|
||||
await controller?.dispose()
|
||||
this.sessions.delete(sessionId)
|
||||
this.sessionStates.delete(sessionId)
|
||||
}
|
||||
@@ -1143,48 +1161,6 @@ export class ClineAgent implements acp.Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has authentication configured.
|
||||
* Returns true if they have either:
|
||||
* - Cline provider with stored auth data
|
||||
* - OpenAI Codex provider with OAuth credentials
|
||||
* - BYO provider with an API key configured
|
||||
*/
|
||||
private async isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
|
||||
|
||||
if (currentProvider === "cline") {
|
||||
// For Cline provider, check if we have stored auth data
|
||||
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
|
||||
return values.some(Boolean)
|
||||
}
|
||||
|
||||
// For OpenAI Codex provider, check OAuth credentials
|
||||
if (currentProvider === "openai-codex") {
|
||||
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
||||
return await openAiCodexOAuthManager.isAuthenticated()
|
||||
}
|
||||
|
||||
// For BYO providers, check if the API key is configured
|
||||
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (!keyField) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
const value = await secretStorage.get(field)
|
||||
if (value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OpenAI Codex OAuth authentication flow.
|
||||
*
|
||||
@@ -1198,9 +1174,6 @@ export class ClineAgent implements acp.Agent {
|
||||
Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...")
|
||||
|
||||
try {
|
||||
// Initialize the OAuth manager with extension context
|
||||
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
||||
|
||||
// Get the authorization URL and start the callback server
|
||||
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import type { ClineSessionEvents } from "./types.js"
|
||||
import type { ClineSessionEvents } from "./public-types.js"
|
||||
|
||||
/**
|
||||
* Type-safe EventEmitter for ClineAgent session events.
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { createSessionState, translateMessage, translateMessages } from "./messageTranslator"
|
||||
import type { AcpSessionState } from "./types"
|
||||
import { AcpSessionStatus } from "./types"
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
@@ -175,8 +176,7 @@ describe("createSessionState", () => {
|
||||
const state = createSessionState("my-session-123")
|
||||
|
||||
expect(state.sessionId).toBe("my-session-123")
|
||||
expect(state.isProcessing).toBe(false)
|
||||
expect(state.cancelled).toBe(false)
|
||||
expect(state.status).toBe(AcpSessionStatus.Idle)
|
||||
expect(state.pendingToolCalls).toBeInstanceOf(Map)
|
||||
expect(state.pendingToolCalls.size).toBe(0)
|
||||
expect(state.currentToolCallId).toBeUndefined()
|
||||
@@ -187,11 +187,11 @@ describe("createSessionState", () => {
|
||||
const state2 = createSessionState("session-2")
|
||||
|
||||
// Modify state1
|
||||
state1.isProcessing = true
|
||||
state1.status = AcpSessionStatus.Processing
|
||||
state1.pendingToolCalls.set("tool-1", {} as acp.ToolCall)
|
||||
|
||||
// state2 should be unaffected
|
||||
expect(state2.isProcessing).toBe(false)
|
||||
expect(state2.status).toBe(AcpSessionStatus.Idle)
|
||||
expect(state2.pendingToolCalls.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { ClineMessage, ClineSayBrowserAction, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import type { AcpSessionState, TranslatedMessage } from "./types.js"
|
||||
import { AcpSessionStatus } from "./types.js"
|
||||
|
||||
/**
|
||||
* Maps Cline tool types to ACP ToolKind values.
|
||||
@@ -312,6 +313,10 @@ function translateSayMessage(
|
||||
// API request finished - no specific update needed
|
||||
break
|
||||
|
||||
case "subagent_usage":
|
||||
// Hidden aggregate metrics event used for task-level accounting.
|
||||
break
|
||||
|
||||
case "task":
|
||||
// Task started - don't echo the user's prompt back to them
|
||||
// The ACP client already knows what they typed
|
||||
@@ -1015,8 +1020,7 @@ export function translateMessages(messages: ClineMessage[], sessionState: AcpSes
|
||||
export function createSessionState(sessionId: string): AcpSessionState {
|
||||
return {
|
||||
sessionId,
|
||||
isProcessing: false,
|
||||
cancelled: false,
|
||||
status: AcpSessionStatus.Idle,
|
||||
pendingToolCalls: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Public types for the Cline library API.
|
||||
*
|
||||
* This file contains types that are safe to export to library consumers.
|
||||
* It must NOT import any internal types (Controller, StateManager, etc.)
|
||||
* to keep the generated declaration files clean.
|
||||
*
|
||||
* Internal-only extensions of these types live in ./types.ts.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
|
||||
// ============================================================
|
||||
// Session Update Type Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Different types of updates that can be sent during session processing.
|
||||
*
|
||||
* These updates provide real-time feedback about the agent's progress.
|
||||
*
|
||||
* See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)
|
||||
*/
|
||||
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
|
||||
|
||||
/**
|
||||
* Different types of update payloads that can be sent during session processing.
|
||||
*
|
||||
* Each update type has a corresponding payload structure defined in the ACP SessionUpdate union.
|
||||
*/
|
||||
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
|
||||
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
|
||||
"sessionUpdate"
|
||||
>
|
||||
|
||||
// ============================================================
|
||||
// Permission Handler Callback Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Handler function for permission requests.
|
||||
* Called when the agent needs permission for a tool call.
|
||||
* The handler should present the request to the user and call resolve() with their response.
|
||||
*/
|
||||
export type PermissionHandler = (request: acp.RequestPermissionRequest) => Promise<acp.RequestPermissionResponse>
|
||||
|
||||
// ============================================================
|
||||
// Session Event Emitter Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Maps ACP SessionUpdate types to their event listener signatures.
|
||||
* Uses the sessionUpdate discriminator to derive event names and payload types.
|
||||
*/
|
||||
export type ClineSessionEvents = {
|
||||
[K in SessionUpdateType]: (payload: SessionUpdatePayload<K>) => void
|
||||
} & {
|
||||
/** Error event for session-level errors (not part of ACP SessionUpdate) */
|
||||
error: (error: Error) => void
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ClineAgent Options
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Options for creating a ClineAgent instance.
|
||||
*/
|
||||
export interface ClineAgentOptions {
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
/** Cline Config Directory (defaults to ~/.cline) */
|
||||
clineDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an ACP agent instance.
|
||||
*/
|
||||
export interface AcpAgentOptions {
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Types
|
||||
// ============================================================
|
||||
export type SessionID = string
|
||||
|
||||
/**
|
||||
* Extended session data stored by Cline for ACP sessions.
|
||||
*/
|
||||
export interface ClineAcpSession {
|
||||
/** Unique session ID */
|
||||
sessionId: SessionID
|
||||
/** Working directory for the session */
|
||||
cwd: string
|
||||
/** Current mode (plan/act) */
|
||||
mode: "plan" | "act"
|
||||
/** MCP servers passed from the client */
|
||||
mcpServers: acp.McpServer[]
|
||||
/** Timestamp when session was created */
|
||||
createdAt: number
|
||||
/** Timestamp of last activity */
|
||||
lastActivityAt: number
|
||||
/** Whether this session was loaded from history (needs resume on first prompt) */
|
||||
isLoadedFromHistory?: boolean
|
||||
/** Model ID override for plan mode (format: "provider/modelId") */
|
||||
planModeModelId?: string
|
||||
/** Model ID override for act mode (format: "provider/modelId") */
|
||||
actModeModelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of an ACP session.
|
||||
*
|
||||
* Represents the state machine:
|
||||
* Idle → Processing → Idle (normal completion)
|
||||
* Idle → Processing → Cancelled (cancellation, then back to Idle on next prompt)
|
||||
*/
|
||||
export enum AcpSessionStatus {
|
||||
/** Session is idle, waiting for a prompt */
|
||||
Idle = "idle",
|
||||
/** Session is actively processing a prompt */
|
||||
Processing = "processing",
|
||||
/** Session processing was cancelled */
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
/**
|
||||
* State tracking for an active ACP session within Cline.
|
||||
*/
|
||||
export interface AcpSessionState {
|
||||
/** Session ID */
|
||||
sessionId: SessionID
|
||||
/** Current lifecycle status of the session */
|
||||
status: AcpSessionStatus
|
||||
/** Current tool call ID being executed (if any) */
|
||||
currentToolCallId?: string
|
||||
/** Accumulated tool calls for permission batching */
|
||||
pendingToolCalls: Map<string, acp.ToolCall>
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Agent Capabilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Cline-specific agent capabilities extending the ACP base capabilities.
|
||||
*/
|
||||
export interface ClineAgentCapabilities {
|
||||
/** Support for loading sessions from disk */
|
||||
loadSession: boolean
|
||||
/** Prompt capabilities for the agent */
|
||||
promptCapabilities: {
|
||||
/** Support for image inputs */
|
||||
image: boolean
|
||||
/** Support for audio inputs */
|
||||
audio: boolean
|
||||
/** Support for embedded context (file resources) */
|
||||
embeddedContext: boolean
|
||||
}
|
||||
/** MCP server passthrough capabilities */
|
||||
mcpCapabilities: {
|
||||
/** Support for HTTP MCP servers */
|
||||
http: boolean
|
||||
/** Support for SSE MCP servers */
|
||||
sse: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cline agent info for ACP initialization response.
|
||||
*/
|
||||
export interface ClineAgentInfo {
|
||||
name: "cline"
|
||||
title: "Cline"
|
||||
version: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Permission Options
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Permission option as presented to the ACP client.
|
||||
*/
|
||||
export interface ClinePermissionOption {
|
||||
kind: acp.PermissionOptionKind
|
||||
name: string
|
||||
optionId: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Translation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Result of translating a Cline message to ACP session update(s).
|
||||
* A single Cline message may produce multiple ACP updates.
|
||||
*/
|
||||
export interface TranslatedMessage {
|
||||
/** The session updates to send */
|
||||
updates: acp.SessionUpdate[]
|
||||
/** Whether this message requires a permission request */
|
||||
requiresPermission?: boolean
|
||||
/** Permission request details if required */
|
||||
permissionRequest?: Omit<acp.RequestPermissionRequest, "sessionId">
|
||||
/** The toolCallId that was created/used (for tracking across streaming updates) */
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Re-exported ACP Types
|
||||
// ============================================================
|
||||
|
||||
export type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AudioContent,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolCallUpdate,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
+20
-199
@@ -1,76 +1,13 @@
|
||||
/**
|
||||
* Custom types and extensions for ACP integration with Cline CLI.
|
||||
* Internal types for ACP integration with Cline CLI.
|
||||
*
|
||||
* This file extends the base ACP types with Cline-specific functionality.
|
||||
* This file re-exports all public types from ./public-types.ts and adds
|
||||
* internal-only Types that reference core modules (Controller, etc.).
|
||||
*
|
||||
* Library consumers should never import from this file directly — they
|
||||
* get the public types via the library entrypoint (exports.ts).
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { Controller } from "@/core/controller"
|
||||
|
||||
// ============================================================
|
||||
// Session Update Type Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Extract the sessionUpdate discriminator value from a SessionUpdate variant.
|
||||
*/
|
||||
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
|
||||
|
||||
/**
|
||||
* Extract the payload type for a given sessionUpdate discriminator value.
|
||||
* This removes the `sessionUpdate` discriminator field from the type.
|
||||
*/
|
||||
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
|
||||
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
|
||||
"sessionUpdate"
|
||||
>
|
||||
|
||||
// ============================================================
|
||||
// Permission Handler Callback Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Callback to resolve a permission request with the user's response.
|
||||
*/
|
||||
export type PermissionResolver = (response: acp.RequestPermissionResponse) => void
|
||||
|
||||
/**
|
||||
* Handler function for permission requests.
|
||||
* Called when the agent needs permission for a tool call.
|
||||
* The handler should present the request to the user and call resolve() with their response.
|
||||
*/
|
||||
export type PermissionHandler = (request: Omit<acp.RequestPermissionRequest, "sessionId">, resolve: PermissionResolver) => void
|
||||
|
||||
// ============================================================
|
||||
// Session Event Emitter Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Maps ACP SessionUpdate types to their event listener signatures.
|
||||
* Uses the sessionUpdate discriminator to derive event names and payload types.
|
||||
*/
|
||||
export type ClineSessionEvents = {
|
||||
[K in SessionUpdateType]: (payload: SessionUpdatePayload<K>) => void
|
||||
} & {
|
||||
/** Error event for session-level errors (not part of ACP SessionUpdate) */
|
||||
error: (error: Error) => void
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ClineAgent Options (decoupled from connection)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Options for creating a ClineAgent instance (decoupled from connection).
|
||||
*/
|
||||
export interface ClineAgentOptions {
|
||||
/** CLI version string */
|
||||
version: string
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
// Re-export common ACP types for convenience
|
||||
export type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
@@ -114,134 +51,18 @@ export type {
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
/**
|
||||
* Cline-specific agent capabilities extending the ACP base capabilities.
|
||||
*/
|
||||
export interface ClineAgentCapabilities {
|
||||
/** Support for loading sessions from disk */
|
||||
loadSession: boolean
|
||||
/** Prompt capabilities for the agent */
|
||||
promptCapabilities: {
|
||||
/** Support for image inputs */
|
||||
image: boolean
|
||||
/** Support for audio inputs */
|
||||
audio: boolean
|
||||
/** Support for embedded context (file resources) */
|
||||
embeddedContext: boolean
|
||||
}
|
||||
/** MCP server passthrough capabilities */
|
||||
mcpCapabilities: {
|
||||
/** Support for HTTP MCP servers */
|
||||
http: boolean
|
||||
/** Support for SSE MCP servers */
|
||||
sse: boolean
|
||||
}
|
||||
}
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
ClineAgentCapabilities,
|
||||
ClineAgentInfo,
|
||||
ClineAgentOptions,
|
||||
ClinePermissionOption,
|
||||
ClineSessionEvents,
|
||||
PermissionHandler,
|
||||
SessionUpdatePayload,
|
||||
SessionUpdateType,
|
||||
TranslatedMessage,
|
||||
} from "./public-types.js"
|
||||
|
||||
/**
|
||||
* Cline agent info for ACP initialization response.
|
||||
*/
|
||||
export interface ClineAgentInfo {
|
||||
name: "cline"
|
||||
title: "Cline"
|
||||
version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended session data stored by Cline for ACP sessions.
|
||||
* Maps to Cline's task history structure.
|
||||
*/
|
||||
export interface ClineAcpSession {
|
||||
/** Unique session/task ID */
|
||||
sessionId: string
|
||||
/** Working directory for the session */
|
||||
cwd: string
|
||||
/** Current mode (plan/act) */
|
||||
mode: "plan" | "act"
|
||||
/** MCP servers passed from the client */
|
||||
mcpServers: acp.McpServer[]
|
||||
/** Timestamp when session was created */
|
||||
createdAt: number
|
||||
/** Timestamp of last activity */
|
||||
lastActivityAt: number
|
||||
/** Whether this session was loaded from history (needs resume on first prompt) */
|
||||
isLoadedFromHistory?: boolean
|
||||
/** Controller instance for this session (manages task execution) */
|
||||
controller?: Controller
|
||||
/** Model ID override for plan mode (format: "provider/modelId") */
|
||||
planModeModelId?: string
|
||||
/** Model ID override for act mode (format: "provider/modelId") */
|
||||
actModeModelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission option as presented to the ACP client.
|
||||
*/
|
||||
export interface ClinePermissionOption {
|
||||
kind: acp.PermissionOptionKind
|
||||
name: string
|
||||
optionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping of Cline message types to their ACP session update equivalents.
|
||||
*/
|
||||
export type ClineToAcpUpdateMapping = {
|
||||
/** Text messages from the agent */
|
||||
text: "agent_message_chunk"
|
||||
/** Reasoning/thinking from the agent */
|
||||
reasoning: "agent_thought_chunk"
|
||||
/** Markdown content from the agent */
|
||||
markdown: "agent_message_chunk"
|
||||
/** Tool execution */
|
||||
tool: "tool_call"
|
||||
/** Command execution */
|
||||
command: "tool_call"
|
||||
/** Command output */
|
||||
command_output: "tool_call_update"
|
||||
/** Task completion */
|
||||
completion_result: "end_turn"
|
||||
/** Error messages */
|
||||
error: "tool_call_update" | "error"
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an ACP agent instance.
|
||||
*/
|
||||
export interface AcpAgentOptions {
|
||||
/** CLI version string */
|
||||
version: string
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of translating a Cline message to ACP session update(s).
|
||||
* A single Cline message may produce multiple ACP updates.
|
||||
*/
|
||||
export interface TranslatedMessage {
|
||||
/** The session updates to send */
|
||||
updates: acp.SessionUpdate[]
|
||||
/** Whether this message requires a permission request */
|
||||
requiresPermission?: boolean
|
||||
/** Permission request details if required */
|
||||
permissionRequest?: Omit<acp.RequestPermissionRequest, "sessionId">
|
||||
/** The toolCallId that was created/used (for tracking across streaming updates) */
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* State tracking for an active ACP session within Cline.
|
||||
*/
|
||||
export interface AcpSessionState {
|
||||
/** Session ID */
|
||||
sessionId: string
|
||||
/** Whether the session is currently processing a prompt */
|
||||
isProcessing: boolean
|
||||
/** Current tool call ID being executed (if any) */
|
||||
currentToolCallId?: string
|
||||
/** Whether the session has been cancelled */
|
||||
cancelled: boolean
|
||||
/** Accumulated tool calls for permission batching */
|
||||
pendingToolCalls: Map<string, acp.ToolCall>
|
||||
}
|
||||
export { AcpSessionStatus } from "./public-types.js"
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
// biome-ignore lint/style/useImportType: React is used as a value by JSX (jsx: "react" in tsconfig)
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { liteLlmDefaultModelId, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
@@ -22,6 +24,7 @@ import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-confi
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
@@ -30,7 +33,8 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getProviderLabel } from "./ProviderPicker"
|
||||
|
||||
type AuthStep =
|
||||
@@ -43,11 +47,13 @@ type AuthStep =
|
||||
| "success"
|
||||
| "error"
|
||||
| "cline_auth"
|
||||
| "oca_employee_check"
|
||||
| "oca_auth"
|
||||
| "cline_model"
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
| "bedrock_custom"
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
@@ -73,7 +79,7 @@ const Select: React.FC<{
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
(_, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
@@ -139,7 +145,11 @@ const TextInput: React.FC<{
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="white">{displayValue || placeholder || ""}</Text>
|
||||
{!displayValue && placeholder ? (
|
||||
<Text color="gray">e.g. {placeholder}</Text>
|
||||
) : (
|
||||
<Text color="white">{displayValue || ""}</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -160,10 +170,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [modelId, setModelId] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const [authStatus, setAuthStatus] = useState<string>("")
|
||||
const [providerSearch, setProviderSearch] = useState("")
|
||||
const [providerIndex, setProviderIndex] = useState(0)
|
||||
const [clineModelIndex, setClineModelIndex] = useState(0)
|
||||
const featuredModels = useClineFeaturedModels()
|
||||
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
@@ -171,11 +181,14 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
// OCA auth hook - enabled when step is oca_auth
|
||||
const handleOcaAuthSuccess = useCallback(async () => {
|
||||
await applyProviderConfig({ providerId: "oca", controller })
|
||||
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
|
||||
await refreshOcaModels(controller, StringRequest.create({ value: "" }))
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
setSelectedProvider("oca")
|
||||
setModelId(liteLlmDefaultModelId)
|
||||
const actModelId = stateManager.getGlobalSettingsKey("actModeOcaModelId") || ""
|
||||
setModelId(actModelId)
|
||||
setStep("success")
|
||||
}, [controller])
|
||||
|
||||
@@ -243,6 +256,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}, [])
|
||||
|
||||
// Reset provider index when search changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here
|
||||
useEffect(() => {
|
||||
setProviderIndex(0)
|
||||
}, [providerSearch])
|
||||
@@ -268,7 +282,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
if (authState.user?.email) {
|
||||
// Auth succeeded - save configuration and transition to model selection
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setSelectedProvider("cline")
|
||||
@@ -317,7 +331,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const startClineAuth = useCallback(async () => {
|
||||
try {
|
||||
setStep("cline_auth")
|
||||
setAuthStatus("Starting authentication...")
|
||||
await AuthService.getInstance(controller).createAuthRequest()
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
@@ -327,7 +340,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
|
||||
const startOcaAuth = useCallback(() => {
|
||||
setStep("oca_auth")
|
||||
setAuthStatus("Starting authentication...")
|
||||
initiateOcaAuth()
|
||||
}, [initiateOcaAuth])
|
||||
|
||||
@@ -358,7 +370,8 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "oca") {
|
||||
startOcaAuth()
|
||||
// Show employee check screen before starting auth
|
||||
setStep("oca_employee_check")
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
@@ -385,6 +398,33 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
// Save custom Bedrock ARN configuration with base model for capability detection
|
||||
const saveCustomBedrockConfiguration = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
try {
|
||||
if (!bedrockConfig) {
|
||||
throw new Error("Bedrock configuration is missing")
|
||||
}
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[bedrockConfig, controller],
|
||||
)
|
||||
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
@@ -419,6 +459,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
(value: string) => {
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") {
|
||||
setStep("bedrock_custom")
|
||||
return
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
setModelId(value)
|
||||
}
|
||||
@@ -526,6 +572,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
// Go back to cline_model if we came from there (Cline provider)
|
||||
if (selectedProvider === "cline") {
|
||||
setStep("cline_model")
|
||||
} else if (selectedProvider === "bedrock") {
|
||||
// Bedrock skips the API key step — go back to Bedrock setup
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
@@ -534,9 +583,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setBaseUrl("")
|
||||
setStep("modelid")
|
||||
break
|
||||
case "oca_auth":
|
||||
case "oca_employee_check":
|
||||
setStep("provider")
|
||||
break
|
||||
case "oca_auth":
|
||||
setStep("oca_employee_check")
|
||||
break
|
||||
case "cline_auth":
|
||||
setStep("menu")
|
||||
break
|
||||
@@ -639,7 +691,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Model ID</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-6, gpt-4o</Text>
|
||||
<Text> </Text>
|
||||
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
|
||||
<Text> </Text>
|
||||
@@ -675,6 +727,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "oca_employee_check":
|
||||
return <OcaEmployeeCheck isActive={step === "oca_employee_check"} onCancel={goBack} onSignIn={startOcaAuth} />
|
||||
|
||||
case "oca_auth":
|
||||
case "cline_auth":
|
||||
return (
|
||||
@@ -714,7 +769,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Choose a model</Text>
|
||||
<Text> </Text>
|
||||
<FeaturedModelPicker selectedIndex={clineModelIndex} />
|
||||
<FeaturedModelPicker featuredModels={featuredModels} selectedIndex={clineModelIndex} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -731,6 +786,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
/>
|
||||
)
|
||||
|
||||
case "bedrock_custom":
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={step === "bedrock_custom"}
|
||||
onCancel={() => setStep("modelid")}
|
||||
onComplete={(arn, baseModelId) => {
|
||||
setStep("saving")
|
||||
saveCustomBedrockConfiguration(arn, baseModelId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
@@ -760,6 +827,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [menuIndex, setMenuIndex] = useState(0)
|
||||
|
||||
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
|
||||
// OcaEmployeeCheck handles its own escape key, so oca_employee_check is not in this list
|
||||
const canGoBack = [
|
||||
"provider",
|
||||
"modelid",
|
||||
@@ -803,17 +871,17 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setProviderSearch((prev) => prev + input)
|
||||
}
|
||||
} else if (step === "cline_model") {
|
||||
const maxIndex = getFeaturedModelMaxIndex()
|
||||
const maxIndex = getFeaturedModelMaxIndex(featuredModels)
|
||||
|
||||
if (key.upArrow) {
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (isBrowseAllSelected(clineModelIndex)) {
|
||||
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
|
||||
setStep("modelid")
|
||||
} else {
|
||||
const selectedModel = getFeaturedModelAtIndex(clineModelIndex)
|
||||
const selectedModel = getFeaturedModelAtIndex(clineModelIndex, featuredModels)
|
||||
if (selectedModel) {
|
||||
handleClineModelSelect(selectedModel.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Bedrock Custom Model Flow component
|
||||
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
type FlowStep = "arn_input" | "base_model"
|
||||
|
||||
interface BedrockCustomModelFlowProps {
|
||||
/** Whether this component should capture keyboard input */
|
||||
isActive: boolean
|
||||
/** Called when the user completes both steps (ARN + base model selection) */
|
||||
onComplete: (arn: string, baseModelId: string) => void
|
||||
/** Called when the user presses Escape on the first step (ARN input) */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<FlowStep>("arn_input")
|
||||
const [customArn, setCustomArn] = useState("")
|
||||
|
||||
const handleArnSubmit = useCallback(() => {
|
||||
if (customArn.trim()) {
|
||||
setStep("base_model")
|
||||
}
|
||||
}, [customArn])
|
||||
|
||||
const handleBaseModelCancel = useCallback(() => {
|
||||
setStep("arn_input")
|
||||
}, [])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setCustomArn((prev) => prev + input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "base_model") {
|
||||
if (key.escape) {
|
||||
handleBaseModelCancel()
|
||||
}
|
||||
// Other input is handled by SearchableList
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
if (step === "arn_input") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Custom Model ID
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{customArn ? (
|
||||
<Text color="white">{customArn}</Text>
|
||||
) : (
|
||||
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// step === "base_model"
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Base Inference Model
|
||||
</Text>
|
||||
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
|
||||
<Box marginTop={1}>
|
||||
<SearchableList
|
||||
isActive={isActive && step === "base_model"}
|
||||
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
|
||||
onSelect={(item) => {
|
||||
onComplete(customArn, item.id)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -114,8 +114,11 @@ export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete
|
||||
|
||||
// Filtered regions
|
||||
const filteredRegions = useMemo(() => {
|
||||
const search = regionSearch.toLowerCase()
|
||||
return search ? AWS_REGIONS.filter((r) => r.includes(search)) : AWS_REGIONS
|
||||
const search = regionSearch.toLowerCase().trim()
|
||||
if (!search) {
|
||||
return AWS_REGIONS
|
||||
}
|
||||
return AWS_REGIONS.filter((r) => r.toLowerCase().includes(search))
|
||||
}, [regionSearch])
|
||||
|
||||
const {
|
||||
@@ -170,10 +173,18 @@ export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete
|
||||
}
|
||||
}, [step, authMethod, onCancel])
|
||||
|
||||
const getSelectedRegion = useCallback(() => {
|
||||
if (filteredRegions.length > 0 && regionIndex >= 0 && regionIndex < filteredRegions.length) {
|
||||
return filteredRegions[regionIndex]
|
||||
}
|
||||
// If no matches, use the search term as custom region
|
||||
return regionSearch.trim() || "us-east-1"
|
||||
}, [filteredRegions, regionIndex, regionSearch])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
const config: BedrockConfig = {
|
||||
awsAuthentication: authMethod === "default" ? "credentials" : authMethod,
|
||||
awsRegion: filteredRegions[regionIndex] || "us-east-1",
|
||||
awsRegion: getSelectedRegion(),
|
||||
awsUseCrossRegionInference: crossRegion,
|
||||
}
|
||||
if (authMethod === "profile") {
|
||||
@@ -184,7 +195,7 @@ export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete
|
||||
if (sessionToken) config.awsSessionToken = sessionToken
|
||||
}
|
||||
onComplete(config)
|
||||
}, [authMethod, profileName, accessKey, secretKey, sessionToken, filteredRegions, regionIndex, crossRegion, onComplete])
|
||||
}, [authMethod, profileName, accessKey, secretKey, sessionToken, getSelectedRegion, crossRegion, onComplete])
|
||||
|
||||
// Handle input for auth_method, region, and options steps
|
||||
useInput(
|
||||
@@ -204,11 +215,11 @@ export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete
|
||||
} else if (step === "region") {
|
||||
if (key.escape) {
|
||||
goBack()
|
||||
} else if (key.upArrow) {
|
||||
} else if (key.upArrow && filteredRegions.length > 0) {
|
||||
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
} else if (key.downArrow && filteredRegions.length > 0) {
|
||||
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return && filteredRegions.length > 0) {
|
||||
} else if (key.return && (filteredRegions.length > 0 || regionSearch.trim())) {
|
||||
setStep("options")
|
||||
} else if (key.backspace || key.delete) {
|
||||
setRegionSearch((prev) => prev.slice(0, -1))
|
||||
@@ -330,7 +341,7 @@ export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete
|
||||
<Text color="white">AWS Region</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="gray">Search or enter custom region: </Text>
|
||||
<Text color="white">{regionSearch}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
@@ -350,7 +361,6 @@ export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete
|
||||
{showRegionBottom && (
|
||||
<Text color="gray">... {filteredRegions.length - regionVisibleStart - regionVisibleCount} more below</Text>
|
||||
)}
|
||||
{filteredRegions.length === 0 && <Text color="gray">No regions match "{regionSearch}"</Text>}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ChatMessage } from "./ChatMessage"
|
||||
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
resizeKey: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("ChatMessage subagent rendering", () => {
|
||||
it("renders subagent approval prompts as a tree", () => {
|
||||
const message: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "ask",
|
||||
ask: "use_subagents",
|
||||
text: JSON.stringify({
|
||||
prompts: [
|
||||
"Find codebase stats and size",
|
||||
"Find funny comments and easter eggs",
|
||||
"Find unusual patterns and history",
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const { lastFrame } = render(React.createElement(ChatMessage, { message, mode: "act" }))
|
||||
const frame = lastFrame() || ""
|
||||
|
||||
expect(frame).toContain("Cline wants to run subagents")
|
||||
expect(frame).toContain("├─ Find codebase stats and size")
|
||||
expect(frame).toContain("├─ Find funny comments and easter eggs")
|
||||
expect(frame).toContain("└─ Find unusual patterns and history")
|
||||
})
|
||||
|
||||
it("renders subagent progress rows with compact token stats and completion checks", () => {
|
||||
const message: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "subagent",
|
||||
text: JSON.stringify({
|
||||
status: "running",
|
||||
total: 3,
|
||||
completed: 1,
|
||||
successes: 1,
|
||||
failures: 0,
|
||||
toolCalls: 21,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
contextWindow: 0,
|
||||
maxContextTokens: 0,
|
||||
maxContextUsagePercentage: 0,
|
||||
items: [
|
||||
{
|
||||
index: 1,
|
||||
prompt: "Find codebase stats and size",
|
||||
status: "completed",
|
||||
toolCalls: 5,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0.034,
|
||||
contextTokens: 24400,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 12.2,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
prompt: "Find funny comments and easter eggs",
|
||||
status: "running",
|
||||
toolCalls: 11,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0.056,
|
||||
contextTokens: 31600,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 15.8,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
prompt: "Find unusual patterns and history",
|
||||
status: "pending",
|
||||
toolCalls: 5,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0,
|
||||
contextTokens: 28900,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 14.4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const { lastFrame } = render(React.createElement(ChatMessage, { isStreaming: true, message, mode: "act" }))
|
||||
const frame = lastFrame() || ""
|
||||
|
||||
expect(frame).toContain("Cline is running subagents")
|
||||
expect(frame).toContain("✓ Find codebase stats and size")
|
||||
expect(frame).toContain("5 tool uses · 24.4k tokens · $0.03")
|
||||
expect(frame).toContain("11 tool uses · 31.6k tokens · $0.06")
|
||||
expect(frame).toContain("5 tool uses · 28.9k tokens · $0.00")
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { getToolDescription, isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { DiffView } from "./DiffView"
|
||||
import { SubagentMessage } from "./SubagentMessage"
|
||||
|
||||
/**
|
||||
* Add "(Tab)" hint after "Act mode" mentions.
|
||||
@@ -24,7 +25,7 @@ import { DiffView } from "./DiffView"
|
||||
* Matches just "Act mode" without requiring "to " prefix because markdown
|
||||
* processing may split "toggle to **Act mode**" into separate text chunks.
|
||||
*/
|
||||
function addActModeHint(text: string): React.ReactNode[] {
|
||||
function addActModeHint(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
// Match "Act mode" in various capitalizations, but not if already followed by (Tab)
|
||||
const actModeRegex = /\bact\s+mode\b(?!\s*\(tab\))/gi
|
||||
const parts = text.split(actModeRegex)
|
||||
@@ -41,7 +42,7 @@ function addActModeHint(text: string): React.ReactNode[] {
|
||||
}
|
||||
if (matches[i]) {
|
||||
nodes.push(
|
||||
<React.Fragment key={`act-mode-${i}`}>
|
||||
<React.Fragment key={`${keyPrefix}-act-mode-${i}`}>
|
||||
{matches[i]}
|
||||
<Text color="gray"> (Tab)</Text>
|
||||
</React.Fragment>,
|
||||
@@ -59,6 +60,8 @@ function addActModeHint(text: string): React.ReactNode[] {
|
||||
*/
|
||||
function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
let hintCallIndex = 0
|
||||
const addHintedText = (value: string) => addActModeHint(value, `hint-${hintCallIndex++}`)
|
||||
// Match **bold**, *italic*, or `code` - order matters (** before *)
|
||||
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
|
||||
let lastIndex = 0
|
||||
@@ -68,7 +71,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
// Add text before match (with Act Mode hint processing)
|
||||
if (match.index > lastIndex) {
|
||||
const beforeText = text.slice(lastIndex, match.index)
|
||||
nodes.push(...addActModeHint(beforeText))
|
||||
nodes.push(...addHintedText(beforeText))
|
||||
}
|
||||
|
||||
const fullMatch = match[0]
|
||||
@@ -77,7 +80,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
|
||||
// Bold - also process for Act Mode hints inside bold text
|
||||
const boldContent = fullMatch.slice(2, -2)
|
||||
const hintedContent = addActModeHint(boldContent)
|
||||
const hintedContent = addHintedText(boldContent)
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{hintedContent}
|
||||
@@ -100,10 +103,10 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
|
||||
// Add remaining text (with Act Mode hint processing)
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(...addActModeHint(text.slice(lastIndex)))
|
||||
nodes.push(...addHintedText(text.slice(lastIndex)))
|
||||
}
|
||||
|
||||
return nodes.length > 0 ? nodes : addActModeHint(text)
|
||||
return nodes.length > 0 ? nodes : addHintedText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,7 +227,7 @@ function truncate(text: string, maxLength: number): string {
|
||||
/**
|
||||
* Format tool result for display
|
||||
*/
|
||||
function formatToolResult(result: string, maxLines: number = 5): string[] {
|
||||
function formatToolResult(result: string, maxLines = 5): string[] {
|
||||
const lines = result.split("\n")
|
||||
if (lines.length <= maxLines) {
|
||||
return lines
|
||||
@@ -446,6 +449,10 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
)
|
||||
}
|
||||
|
||||
if ((type === "ask" && ask === "use_subagents") || say === "use_subagents" || say === "subagent") {
|
||||
return <SubagentMessage isStreaming={isStreaming} message={message} mode={mode} />
|
||||
}
|
||||
|
||||
// MCP response
|
||||
if (say === "mcp_server_response" && text) {
|
||||
const lines = formatToolResult(text, 8)
|
||||
@@ -800,6 +807,8 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxM
|
||||
const displayMessages = messages.filter((m) => {
|
||||
// Skip api_req_finished, they're just markers
|
||||
if (m.say === "api_req_finished") return false
|
||||
// Skip hidden aggregated usage messages
|
||||
if (m.say === "subagent_usage") return false
|
||||
// Skip empty text messages
|
||||
if (m.say === "text" && !m.text?.trim()) return false
|
||||
// Skip checkpoint messages
|
||||
|
||||
@@ -150,9 +150,28 @@ import { HighlightedInput } from "./HighlightedInput"
|
||||
import { HistoryPanelContent } from "./HistoryPanelContent"
|
||||
import { providerModels } from "./ModelPicker"
|
||||
import { SettingsPanelContent } from "./SettingsPanelContent"
|
||||
import { SkillsPanelContent } from "./SkillsPanelContent"
|
||||
import { SlashCommandMenu } from "./SlashCommandMenu"
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator"
|
||||
|
||||
/**
|
||||
* Persistent input storage that survives React remounts (e.g., during terminal resize).
|
||||
* Keyed by a stable identifier so each task/session maintains its own input state.
|
||||
*/
|
||||
interface PersistedInputState {
|
||||
text: string
|
||||
cursorPos: number
|
||||
pastedTexts: Map<number, string>
|
||||
pasteCounter: number
|
||||
}
|
||||
|
||||
const inputStateStorage = new Map<string, PersistedInputState>()
|
||||
|
||||
function getInputStorageKey(controller: any, taskId?: string): string {
|
||||
// Use taskId if available, otherwise fall back to controller instance
|
||||
return taskId || (controller?.task?.taskId ?? "default")
|
||||
}
|
||||
|
||||
interface ChatViewProps {
|
||||
controller?: any
|
||||
onExit?: () => void
|
||||
@@ -351,6 +370,9 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
insertText: insertTextAtCursor,
|
||||
} = useTextInput()
|
||||
|
||||
// Get storage key for persisting input across remounts
|
||||
const storageKey = useMemo(() => getInputStorageKey(ctrl, taskId), [ctrl, taskId])
|
||||
|
||||
// Refs for text input and cursor position (used by useHomeEndKeys and to avoid stale closures in useInput)
|
||||
const textInputRef = useRef(textInput)
|
||||
textInputRef.current = textInput
|
||||
@@ -367,8 +389,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const [userScrolled, setUserScrolled] = useState(false)
|
||||
|
||||
// Pasted text storage - maps placeholder number to full pasted content
|
||||
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(new Map())
|
||||
const pasteCounterRef = useRef(0)
|
||||
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(() => {
|
||||
return inputStateStorage.get(storageKey)?.pastedTexts ?? new Map()
|
||||
})
|
||||
const pasteCounterRef = useRef<number>(inputStateStorage.get(storageKey)?.pasteCounter ?? 0)
|
||||
// Track paste timing to combine chunks that arrive in rapid succession
|
||||
const lastPasteTimeRef = useRef<number>(0)
|
||||
const activePasteNumRef = useRef<number>(0)
|
||||
@@ -389,6 +413,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
|
||||
| { type: "history" }
|
||||
| { type: "help" }
|
||||
| { type: "skills" }
|
||||
| null
|
||||
>(null)
|
||||
|
||||
@@ -402,6 +427,29 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Track when we're exiting to hide UI elements before exit
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
|
||||
// Restore input state from storage on mount (after resize remount)
|
||||
useEffect(() => {
|
||||
const stored = inputStateStorage.get(storageKey)
|
||||
if (stored) {
|
||||
setTextInput(stored.text)
|
||||
setCursorPos(stored.cursorPos)
|
||||
setPastedTexts(stored.pastedTexts)
|
||||
pasteCounterRef.current = stored.pasteCounter
|
||||
}
|
||||
}, [storageKey, setTextInput, setCursorPos])
|
||||
|
||||
// Persist input state to storage whenever it changes (survives remount)
|
||||
useEffect(() => {
|
||||
if (textInput || pastedTexts.size > 0) {
|
||||
inputStateStorage.set(storageKey, {
|
||||
text: textInput,
|
||||
cursorPos,
|
||||
pastedTexts: new Map(pastedTexts),
|
||||
pasteCounter: pasteCounterRef.current,
|
||||
})
|
||||
}
|
||||
}, [storageKey, textInput, cursorPos, pastedTexts])
|
||||
|
||||
// Task switch handling: when switching tasks via /history, we clear the terminal and
|
||||
// increment a counter used as the root Box's key. This forces React to remount the tree,
|
||||
// giving us a fresh Static instance. Mirrors how App.tsx handles resize with resizeKey.
|
||||
@@ -494,12 +542,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
clearState() // Force clear React state (bypasses empty messages check)
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
// Clear persisted state
|
||||
inputStateStorage.delete(storageKey)
|
||||
|
||||
// Post the now-empty state
|
||||
if (ctrl) {
|
||||
ctrl.postStateToWebview()
|
||||
}
|
||||
}, [ctrl, clearState])
|
||||
}, [ctrl, clearState, storageKey])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
@@ -760,6 +810,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setCursorPos(0)
|
||||
setPastedTexts(new Map()) // Clear stored pastes
|
||||
pasteCounterRef.current = 0
|
||||
// Clear persisted state
|
||||
inputStateStorage.delete(storageKey)
|
||||
|
||||
try {
|
||||
await ctrl.task.handleWebviewAskResponse(responseType, expandedText)
|
||||
@@ -767,7 +819,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[ctrl, pendingAsk, pastedTexts],
|
||||
[ctrl, pendingAsk, pastedTexts, storageKey],
|
||||
)
|
||||
|
||||
// Handle cancel/interrupt
|
||||
@@ -858,6 +910,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setCursorPos(0)
|
||||
setPastedTexts(new Map()) // Clear stored pastes
|
||||
pasteCounterRef.current = 0
|
||||
// Clear persisted state
|
||||
inputStateStorage.delete(storageKey)
|
||||
|
||||
try {
|
||||
// Convert image paths to data URLs if needed
|
||||
@@ -885,7 +939,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
onError?.()
|
||||
}
|
||||
},
|
||||
[ctrl, onError, pastedTexts],
|
||||
[ctrl, onError, pastedTexts, storageKey],
|
||||
)
|
||||
|
||||
// Auto-submit initial prompt if provided
|
||||
@@ -1104,13 +1158,21 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "clear") {
|
||||
clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "exit") {
|
||||
if (cmd.name === "exit" || cmd.name === "q") {
|
||||
handleExit()
|
||||
return
|
||||
}
|
||||
@@ -1493,6 +1555,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
{/* Help panel */}
|
||||
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
|
||||
|
||||
{/* Skills panel */}
|
||||
{activePanel?.type === "skills" && ctrl && (
|
||||
<SkillsPanelContent
|
||||
controller={ctrl}
|
||||
onClose={() => setActivePanel(null)}
|
||||
onUseSkill={(skillPath) => {
|
||||
setActivePanel(null)
|
||||
setTextInput(`@${skillPath} `)
|
||||
setCursorPos(skillPath.length + 2)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Slash command menu - below input (takes priority over file menu) */}
|
||||
{showSlashMenu && !activePanel && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { fuzzyFilter } from "../utils/fuzzy-search"
|
||||
import {
|
||||
BooleanSelect,
|
||||
buildConfigEntries,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
HookInfo,
|
||||
HookRow,
|
||||
MAX_VISIBLE,
|
||||
ObjectEditorPanel,
|
||||
ObjectEditorState,
|
||||
parseValue,
|
||||
SEPARATOR,
|
||||
SectionHeader,
|
||||
@@ -105,6 +108,8 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [objectEditor, setObjectEditor] = useState<ObjectEditorState | null>(null)
|
||||
|
||||
// Build entries for settings tab
|
||||
const configEntries = useMemo(
|
||||
@@ -112,6 +117,13 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
[globalState, workspaceState],
|
||||
)
|
||||
|
||||
const filteredConfigEntries = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return configEntries
|
||||
}
|
||||
return fuzzyFilter(configEntries, searchQuery, (entry) => `${entry.key} ${String(entry.value ?? "")}`)
|
||||
}, [configEntries, searchQuery])
|
||||
|
||||
// Build entries for rules tab
|
||||
const ruleEntries = useMemo(() => {
|
||||
const entries: ToggleEntry[] = []
|
||||
@@ -159,7 +171,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
const currentListLength = useMemo(() => {
|
||||
switch (currentTab) {
|
||||
case "settings":
|
||||
return configEntries.length
|
||||
return filteredConfigEntries.length
|
||||
case "rules":
|
||||
return ruleEntries.length
|
||||
case "workflows":
|
||||
@@ -171,7 +183,14 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
|
||||
}, [
|
||||
currentTab,
|
||||
filteredConfigEntries.length,
|
||||
ruleEntries.length,
|
||||
workflowEntries.length,
|
||||
hookEntries.length,
|
||||
skillEntries.length,
|
||||
])
|
||||
|
||||
// Get available tabs
|
||||
const availableTabs = useMemo(() => {
|
||||
@@ -191,10 +210,11 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
setCurrentTab(newTab)
|
||||
setSelectedIndex(0)
|
||||
setIsEditing(false)
|
||||
setObjectEditor(null)
|
||||
}
|
||||
|
||||
// Settings tab handlers
|
||||
const selectedConfigEntry = configEntries[selectedIndex]
|
||||
const selectedConfigEntry = filteredConfigEntries[selectedIndex]
|
||||
|
||||
const handleSettingsSave = (value: string | boolean) => {
|
||||
if (!selectedConfigEntry) {
|
||||
@@ -210,6 +230,43 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const getObjectAtPath = (root: Record<string, unknown>, path: string[]): Record<string, unknown> => {
|
||||
let current: unknown = root
|
||||
for (const segment of path) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return {}
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
return current && typeof current === "object" ? (current as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
const setObjectValueAtPath = (
|
||||
root: Record<string, unknown>,
|
||||
path: string[],
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> => {
|
||||
if (path.length === 0) {
|
||||
return { ...root, [key]: value }
|
||||
}
|
||||
const [head, ...rest] = path
|
||||
const child = root[head]
|
||||
const childObj = child && typeof child === "object" ? (child as Record<string, unknown>) : {}
|
||||
return {
|
||||
...root,
|
||||
[head]: setObjectValueAtPath(childObj, rest, key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const persistObjectEditor = (nextObject: Record<string, unknown>, source: "global" | "workspace", key: string) => {
|
||||
if (source === "global" && onUpdateGlobal) {
|
||||
onUpdateGlobal(key as GlobalStateAndSettingsKey, nextObject as never)
|
||||
} else if (source === "workspace" && onUpdateWorkspace) {
|
||||
onUpdateWorkspace(key as LocalStateKey, nextObject as never)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
|
||||
return
|
||||
@@ -240,15 +297,22 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
// Input handling
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input.toLowerCase() === "q" || key.escape) {
|
||||
if (objectEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
exit()
|
||||
}
|
||||
|
||||
// Tab navigation with Tab key or number keys
|
||||
if (key.tab || (input >= "1" && input <= "5")) {
|
||||
const targetIdx = key.tab
|
||||
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
|
||||
: parseInt(input) - 1
|
||||
if (key.leftArrow || key.rightArrow || (input >= "1" && input <= "5")) {
|
||||
const currentTabIndex = availableTabs.findIndex((t) => t.key === currentTab)
|
||||
const targetIdx =
|
||||
input >= "1" && input <= "5"
|
||||
? Number.parseInt(input) - 1
|
||||
: key.leftArrow
|
||||
? (currentTabIndex - 1 + availableTabs.length) % availableTabs.length
|
||||
: (currentTabIndex + 1) % availableTabs.length
|
||||
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
|
||||
handleTabChange(availableTabs[targetIdx].key)
|
||||
}
|
||||
@@ -256,21 +320,45 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
}
|
||||
|
||||
// List navigation (arrow keys and vim-style j/k)
|
||||
if (key.upArrow || input === "k") {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
|
||||
}
|
||||
|
||||
// Tab-specific actions
|
||||
if (currentTab === "settings") {
|
||||
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
|
||||
if ((key.return || key.tab) && selectedConfigEntry?.isEditable) {
|
||||
if (selectedConfigEntry.type === "boolean") {
|
||||
handleSettingsSave(!selectedConfigEntry.value)
|
||||
return
|
||||
}
|
||||
if (selectedConfigEntry.type === "object") {
|
||||
const value =
|
||||
selectedConfigEntry.value && typeof selectedConfigEntry.value === "object"
|
||||
? (selectedConfigEntry.value as Record<string, unknown>)
|
||||
: {}
|
||||
setObjectEditor({
|
||||
source: selectedConfigEntry.source,
|
||||
key: selectedConfigEntry.key,
|
||||
path: [],
|
||||
value,
|
||||
selectedIndex: 0,
|
||||
isEditingValue: false,
|
||||
editValue: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
|
||||
setIsEditing(true)
|
||||
} else if (input === "r") {
|
||||
} else if (key.ctrl && input.toLowerCase() === "r") {
|
||||
handleSettingsReset()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setSearchQuery((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow) {
|
||||
setSearchQuery((prev) => prev + input)
|
||||
}
|
||||
} else if (key.return || input === " ") {
|
||||
} else if (key.return || key.tab || input === " ") {
|
||||
// Toggle for rules/workflows/hooks/skills
|
||||
handleToggle()
|
||||
}
|
||||
@@ -338,13 +426,31 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (objectEditor && currentTab === "settings") {
|
||||
return (
|
||||
<ObjectEditorPanel
|
||||
getObjectAtPath={getObjectAtPath}
|
||||
onClose={() => setObjectEditor(null)}
|
||||
onPersist={(nextObject) => persistObjectEditor(nextObject, objectEditor.source, objectEditor.key)}
|
||||
setObjectValueAtPath={setObjectValueAtPath}
|
||||
setState={setObjectEditor}
|
||||
state={objectEditor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render tab content
|
||||
const renderTabContent = () => {
|
||||
switch (currentTab) {
|
||||
case "settings": {
|
||||
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
const visibleEntries = filteredConfigEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box>
|
||||
<Text>Search: </Text>
|
||||
<Text color="white">{searchQuery}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Data directory: </Text>
|
||||
<Text color="blue" underline>
|
||||
@@ -507,12 +613,12 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
|
||||
// Help text based on current tab
|
||||
const getHelpText = () => {
|
||||
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
const base = "↑/↓ Navigate • ←/→ tabs • 1-5 tabs • Esc Exit"
|
||||
if (currentTab === "settings") {
|
||||
return `${base} • Enter/e Edit • r Reset`
|
||||
return `${base} • Type to search • Enter/Tab Edit (booleans toggle) • Backspace clear search • Ctrl+R Reset`
|
||||
}
|
||||
const openFolder = onOpenFolder ? " • o Open folder" : ""
|
||||
return `${base} • Enter/Space Toggle${openFolder}`
|
||||
return `${base} • Enter/Tab/Space Toggle${openFolder}`
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,16 +46,19 @@ export interface SkillInfo {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"subagentsEnabled",
|
||||
"subagentTerminalOutputLineLimit",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
])
|
||||
export interface ObjectEditorState {
|
||||
source: "global" | "workspace"
|
||||
key: string
|
||||
path: string[]
|
||||
value: Record<string, unknown>
|
||||
selectedIndex: number
|
||||
isEditingValue: boolean
|
||||
editValue: string
|
||||
}
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
|
||||
export const EXCLUDED_KEYS = new Set(["taskHistory", "primaryRootIndex", "welcomeViewCompleted", "isNewUser"])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
|
||||
export const MAX_VISIBLE = 12
|
||||
export const SEPARATOR = "─".repeat(80)
|
||||
|
||||
@@ -135,7 +138,7 @@ export function parseValue(input: string, type: ValueType): unknown {
|
||||
return input.toLowerCase() === "true" || input === "1"
|
||||
}
|
||||
if (type === "number") {
|
||||
const num = parseFloat(input)
|
||||
const num = Number.parseFloat(input)
|
||||
return Number.isNaN(num) ? 0 : num
|
||||
}
|
||||
if (type === "object") {
|
||||
@@ -217,7 +220,7 @@ export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel,
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="white">{value}</Text>
|
||||
<Text inverse> </Text>
|
||||
<Text color="cyan">|</Text>
|
||||
</Box>
|
||||
<Text color="gray">Type: {type} • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
@@ -379,3 +382,169 @@ export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
interface ObjectEditorPanelProps {
|
||||
state: ObjectEditorState
|
||||
setState: React.Dispatch<React.SetStateAction<ObjectEditorState | null>>
|
||||
onClose: () => void
|
||||
onPersist: (nextObject: Record<string, unknown>) => void
|
||||
getObjectAtPath: (root: Record<string, unknown>, path: string[]) => Record<string, unknown>
|
||||
setObjectValueAtPath: (root: Record<string, unknown>, path: string[], key: string, value: unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
export const ObjectEditorPanel: React.FC<ObjectEditorPanelProps> = ({
|
||||
state,
|
||||
setState,
|
||||
onClose,
|
||||
onPersist,
|
||||
getObjectAtPath,
|
||||
setObjectValueAtPath,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const currentNode = getObjectAtPath(state.value, state.path)
|
||||
const objectEntries = Object.entries(currentNode).sort(([a], [b]) => a.localeCompare(b))
|
||||
const selectedEntry = objectEntries[state.selectedIndex]
|
||||
const breadcrumb = [state.key, ...state.path].join(" › ")
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (state.isEditingValue) {
|
||||
if (key.escape) {
|
||||
setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
if (!selectedEntry) {
|
||||
setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev))
|
||||
return
|
||||
}
|
||||
const [entryKey, entryValue] = selectedEntry
|
||||
let parsed: unknown = state.editValue
|
||||
if (typeof entryValue === "boolean") {
|
||||
parsed = state.editValue.toLowerCase() === "true" || state.editValue === "1"
|
||||
} else if (typeof entryValue === "number") {
|
||||
const maybeNum = Number(state.editValue)
|
||||
parsed = Number.isNaN(maybeNum) ? 0 : maybeNum
|
||||
}
|
||||
const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, parsed)
|
||||
onPersist(nextObject)
|
||||
setState((prev) => (prev ? { ...prev, value: nextObject, isEditingValue: false, editValue: "" } : prev))
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setState((prev) => (prev ? { ...prev, editValue: prev.editValue.slice(0, -1) } : prev))
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
setState((prev) => (prev ? { ...prev, editValue: prev.editValue + input } : prev))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (state.path.length > 0) {
|
||||
setState((prev) => (prev ? { ...prev, path: prev.path.slice(0, -1), selectedIndex: 0 } : prev))
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow || input === "k") {
|
||||
setState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedIndex:
|
||||
objectEntries.length > 0
|
||||
? prev.selectedIndex > 0
|
||||
? prev.selectedIndex - 1
|
||||
: objectEntries.length - 1
|
||||
: 0,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedIndex:
|
||||
objectEntries.length > 0
|
||||
? prev.selectedIndex < objectEntries.length - 1
|
||||
? prev.selectedIndex + 1
|
||||
: 0
|
||||
: 0,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return || key.tab) {
|
||||
if (!selectedEntry) {
|
||||
return
|
||||
}
|
||||
const [entryKey, entryValue] = selectedEntry
|
||||
if (typeof entryValue === "boolean") {
|
||||
const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, !entryValue)
|
||||
onPersist(nextObject)
|
||||
setState((prev) => (prev ? { ...prev, value: nextObject } : prev))
|
||||
return
|
||||
}
|
||||
if (entryValue && typeof entryValue === "object" && !Array.isArray(entryValue)) {
|
||||
setState((prev) => (prev ? { ...prev, path: [...prev.path, entryKey], selectedIndex: 0 } : prev))
|
||||
return
|
||||
}
|
||||
setState((prev) =>
|
||||
prev
|
||||
? { ...prev, isEditingValue: true, editValue: entryValue !== undefined ? String(entryValue) : "" }
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="white">
|
||||
⚙️ Edit Nested Object
|
||||
</Text>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
<Text color="cyan">{breadcrumb}</Text>
|
||||
{state.isEditingValue ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color="white">{state.editValue}</Text>
|
||||
<Text color="cyan">|</Text>
|
||||
</Box>
|
||||
<Text color="gray">Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{objectEntries.length === 0 ? (
|
||||
<Text color="gray">No nested keys at this level.</Text>
|
||||
) : (
|
||||
objectEntries.map(([key, value], idx) => {
|
||||
const isSelected = idx === state.selectedIndex
|
||||
const valueText =
|
||||
value && typeof value === "object" && !Array.isArray(value) ? "{...}" : String(value)
|
||||
return (
|
||||
<Text color={isSelected ? "cyan" : undefined} key={key}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color="cyan">{key}</Text>
|
||||
<Text color="gray">: </Text>
|
||||
<Text color="white">{valueText}</Text>
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<Text color="gray">↑/↓ Navigate • Enter/Tab Edit or drill in • Esc Back/Close</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { type FeaturedModel, getAllFeaturedModels } from "../constants/featured-models"
|
||||
import type { FeaturedModel } from "../constants/featured-models"
|
||||
|
||||
interface FeaturedModelPickerProps {
|
||||
selectedIndex: number
|
||||
title?: string
|
||||
showBrowseAll?: boolean
|
||||
helpText?: string
|
||||
featuredModels: FeaturedModel[]
|
||||
}
|
||||
|
||||
export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
@@ -21,8 +22,9 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
title,
|
||||
showBrowseAll = true,
|
||||
helpText = "Arrows to navigate, Enter to select",
|
||||
featuredModels,
|
||||
}) => {
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
const models = featuredModels
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -35,11 +37,11 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{featuredModels.map((model, i) => {
|
||||
{models.map((model, i) => {
|
||||
const isSelected = i === selectedIndex
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={model.id} marginBottom={1}>
|
||||
<Box flexDirection="column" key={`${model.id}-${model.labels[0] || "default"}`} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>{isSelected ? "❯ " : " "}</Text>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : "white"}>
|
||||
@@ -64,8 +66,8 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
|
||||
{showBrowseAll && (
|
||||
<Box>
|
||||
<Text color={selectedIndex === featuredModels.length ? COLORS.primaryBlue : "white"}>
|
||||
{selectedIndex === featuredModels.length ? "❯ " : " "}
|
||||
<Text color={selectedIndex === models.length ? COLORS.primaryBlue : "white"}>
|
||||
{selectedIndex === models.length ? "❯ " : " "}
|
||||
Browse all models...
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -81,24 +83,21 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
* Get the maximum valid index for the featured model picker
|
||||
* (includes "Browse all" option if showBrowseAll is true)
|
||||
*/
|
||||
export function getFeaturedModelMaxIndex(showBrowseAll: boolean = true): number {
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
export function getFeaturedModelMaxIndex(featuredModels: FeaturedModel[], showBrowseAll = true): number {
|
||||
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selected index is the "Browse all" option
|
||||
*/
|
||||
export function isBrowseAllSelected(selectedIndex: number): boolean {
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
export function isBrowseAllSelected(selectedIndex: number, featuredModels: FeaturedModel[]): boolean {
|
||||
return selectedIndex === featuredModels.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the featured model at the given index, or null if "Browse all" is selected
|
||||
*/
|
||||
export function getFeaturedModelAtIndex(index: number): FeaturedModel | null {
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
export function getFeaturedModelAtIndex(index: number, featuredModels: FeaturedModel[]): FeaturedModel | null {
|
||||
if (index >= 0 && index < featuredModels.length) {
|
||||
return featuredModels[index]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,30 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Keyboard Shortcuts</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+U</Text> - Clear entire input (delete to start)
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+K</Text> - Delete from cursor to end
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+W</Text> - Delete word backwards
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+A / Ctrl+E</Text> - Jump to start / end of input
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Alt/Option+←/→</Text> - Move by word
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Slash Commands</Text>
|
||||
<Text>
|
||||
@@ -64,6 +88,10 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
|
||||
{" "}
|
||||
<Text color="white">/clear</Text> - Start a fresh task
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/q</Text> - Quit Cline
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
|
||||
import { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
|
||||
import {
|
||||
type ApiProvider,
|
||||
@@ -64,11 +65,15 @@ import {
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { filterOpenRouterModelIds } from "@/shared/utils/model-filters"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Special ID used to indicate the user wants to enter a custom model ID / ARN
|
||||
export const CUSTOM_MODEL_ID = "__custom__"
|
||||
|
||||
// Map providers to their static model lists and defaults
|
||||
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
||||
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
|
||||
@@ -105,7 +110,7 @@ export function hasStaticModels(provider: string): boolean {
|
||||
}
|
||||
|
||||
export function hasModelPicker(provider: string): boolean {
|
||||
return hasStaticModels(provider) || usesOpenRouterModels(provider)
|
||||
return hasStaticModels(provider) || usesOpenRouterModels(provider) || provider === "oca"
|
||||
}
|
||||
|
||||
export function getDefaultModelId(provider: string): string {
|
||||
@@ -132,7 +137,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [asyncModels, setAsyncModels] = useState<string[]>([])
|
||||
|
||||
// Fetch OpenRouter models when needed using shared core function
|
||||
// Fetch async models (OpenRouter or OCA) when needed
|
||||
useEffect(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
setIsLoading(true)
|
||||
@@ -145,22 +150,45 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
} else if (provider === "oca") {
|
||||
setIsLoading(true)
|
||||
refreshOcaModels(controller, StringRequest.create({ value: "" }))
|
||||
.then((result) => {
|
||||
if (result.models) {
|
||||
const modelIds = Object.keys(result.models).sort((a, b) => a.localeCompare(b))
|
||||
setAsyncModels(modelIds)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [provider, controller])
|
||||
|
||||
const modelList = useMemo(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
if (usesOpenRouterModels(provider) || provider === "oca") {
|
||||
return asyncModels
|
||||
}
|
||||
return getModelList(provider)
|
||||
}, [provider, asyncModels])
|
||||
|
||||
// Providers that support custom model IDs (e.g., Bedrock Application Inference Profiles)
|
||||
const supportsCustomModel = provider === "bedrock"
|
||||
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
return modelList.map((modelId) => ({
|
||||
const list = modelList.map((modelId) => ({
|
||||
id: modelId,
|
||||
label: modelId,
|
||||
}))
|
||||
}, [modelList])
|
||||
// Add "Custom" option at the end for providers that support it
|
||||
if (supportsCustomModel) {
|
||||
list.push({
|
||||
id: CUSTOM_MODEL_ID,
|
||||
label: "Custom (ARN / Inference Profile)",
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [modelList, supportsCustomModel])
|
||||
|
||||
// For providers without a model picker, render nothing
|
||||
if (!hasModelPicker(provider)) {
|
||||
@@ -180,7 +208,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
}
|
||||
|
||||
// If async fetch returned no models, render nothing
|
||||
if (usesOpenRouterModels(provider) && modelList.length === 0) {
|
||||
if ((usesOpenRouterModels(provider) || provider === "oca") && modelList.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* OCA (Oracle Cloud Assist) employee check component.
|
||||
* Shows a checkbox for "I'm an Oracle Employee" and a sign-in button.
|
||||
* Sets ocaMode in state before triggering the OAuth flow.
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/style/useImportType: React is used as a value by JSX (jsx: "react" in tsconfig)
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
interface OcaEmployeeCheckProps {
|
||||
/** Whether this component is active and should handle input */
|
||||
isActive: boolean
|
||||
/** Called when user confirms and wants to proceed with sign-in */
|
||||
onSignIn: () => void
|
||||
/** Called when user presses Escape to go back */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const OcaEmployeeCheck: React.FC<OcaEmployeeCheckProps> = ({ isActive, onSignIn, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [isEmployee, setIsEmployee] = useState(true) // Default to checked (internal), matching extension behavior
|
||||
const [selectedIndex, setSelectedIndex] = useState(0) // 0 = checkbox, 1 = sign in button
|
||||
|
||||
const ITEM_COUNT = 2
|
||||
|
||||
const handleSignIn = useCallback(async () => {
|
||||
// Persist ocaMode to state before starting auth
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("ocaMode", isEmployee ? "internal" : "external")
|
||||
await stateManager.flushPendingState()
|
||||
onSignIn()
|
||||
}, [isEmployee, onSignIn])
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : ITEM_COUNT - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < ITEM_COUNT - 1 ? prev + 1 : 0))
|
||||
} else if (key.tab || (key.return && selectedIndex === 0)) {
|
||||
// Toggle checkbox when Tab is pressed or Enter on checkbox item
|
||||
if (selectedIndex === 0) {
|
||||
setIsEmployee((prev) => !prev)
|
||||
}
|
||||
} else if (key.return && selectedIndex === 1) {
|
||||
// Sign in button
|
||||
handleSignIn()
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && isActive },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Oracle Code Assist</Text>
|
||||
<Text> </Text>
|
||||
{/* Checkbox: I'm an Oracle Employee */}
|
||||
<Text>
|
||||
<Text bold color={selectedIndex === 0 ? COLORS.primaryBlue : undefined}>
|
||||
{selectedIndex === 0 ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={selectedIndex === 0 || isEmployee ? COLORS.primaryBlue : "gray"}>{isEmployee ? "[✓]" : "[ ]"}</Text>
|
||||
<Text color={selectedIndex === 0 ? COLORS.primaryBlue : "white"}> I'm an Oracle Employee</Text>
|
||||
{selectedIndex === 0 && <Text color="gray"> (Tab to toggle)</Text>}
|
||||
</Text>
|
||||
{/* Sign in button */}
|
||||
<Text>
|
||||
<Text bold color={selectedIndex === 1 ? COLORS.primaryBlue : undefined}>
|
||||
{selectedIndex === 1 ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={selectedIndex === 1 ? COLORS.primaryBlue : "white"}>Sign in with Oracle Code Assist</Text>
|
||||
{selectedIndex === 1 && <Text color="gray"> (Enter)</Text>}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Please ask your IT administrator to set up Oracle Code Assist as a model provider.</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Tab to toggle, Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Mock ink's useApp
|
||||
const mockExit = vi.fn()
|
||||
vi.mock("ink", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ink")>()
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({ exit: mockExit }),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn().mockReturnValue(""),
|
||||
exec: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
|
||||
getGlobalStateKey: vi.fn().mockReturnValue([]),
|
||||
getApiConfiguration: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Session", () => ({
|
||||
Session: {
|
||||
get: () => ({
|
||||
getStats: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskContext: () => ({
|
||||
controller: {},
|
||||
clearState: vi.fn(),
|
||||
}),
|
||||
useTaskState: () => ({
|
||||
clineMessages: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
|
||||
}))
|
||||
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("Quit Command (/q and /exit)", () => {
|
||||
const mockOnExit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should exit the application when /q is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /q
|
||||
stdin.write("/q")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should exit the application when /exit is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /exit
|
||||
stdin.write("/exit")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -7,24 +7,30 @@ import type { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import type { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { getProviderModelIdKey, isSettingsKey, ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import type { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { supportsReasoningEffortForModel } from "@/utils/model-utils"
|
||||
import { version as CLI_VERSION } from "../../package.json"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { Checkbox } from "./Checkbox"
|
||||
import {
|
||||
@@ -34,7 +40,8 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { LanguagePicker } from "./LanguagePicker"
|
||||
import { hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CUSTOM_MODEL_ID, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { OrganizationPicker } from "./OrganizationPicker"
|
||||
import { Panel, PanelTab } from "./Panel"
|
||||
import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
|
||||
@@ -51,13 +58,25 @@ type SettingsTab = "api" | "auto-approve" | "features" | "other" | "account"
|
||||
interface ListItem {
|
||||
key: string
|
||||
label: string
|
||||
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer" | "action"
|
||||
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer" | "action" | "cycle"
|
||||
value: string | boolean
|
||||
description?: string
|
||||
isSubItem?: boolean
|
||||
parentKey?: string
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value: unknown): OpenaiReasoningEffort {
|
||||
if (isOpenaiReasoningEffort(value)) {
|
||||
return value
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
function nextReasoningEffort(current: OpenaiReasoningEffort): OpenaiReasoningEffort {
|
||||
const idx = OPENAI_REASONING_EFFORT_OPTIONS.indexOf(current)
|
||||
return OPENAI_REASONING_EFFORT_OPTIONS[(idx + 1) % OPENAI_REASONING_EFFORT_OPTIONS.length]
|
||||
}
|
||||
|
||||
const TABS: PanelTab[] = [
|
||||
{ key: "api", label: "API" },
|
||||
{ key: "auto-approve", label: "Auto-approve" },
|
||||
@@ -68,6 +87,12 @@ const TABS: PanelTab[] = [
|
||||
|
||||
// Settings configuration for simple boolean toggles
|
||||
const FEATURE_SETTINGS = {
|
||||
subagents: {
|
||||
stateKey: "subagentsEnabled",
|
||||
default: false,
|
||||
label: "Subagents",
|
||||
description: "Let Cline run focused subagents in parallel to explore the codebase for you",
|
||||
},
|
||||
autoCondense: {
|
||||
stateKey: "useAutoCondense",
|
||||
default: false,
|
||||
@@ -98,6 +123,12 @@ const FEATURE_SETTINGS = {
|
||||
label: "Parallel tool calling",
|
||||
description: "Allow multiple tools in a single response",
|
||||
},
|
||||
doubleCheckCompletion: {
|
||||
stateKey: "doubleCheckCompletionEnabled",
|
||||
default: false,
|
||||
label: "Double-check completion",
|
||||
description: "Reject first completion attempt and require re-verification",
|
||||
},
|
||||
} as const
|
||||
|
||||
type FeatureKey = keyof typeof FEATURE_SETTINGS
|
||||
@@ -131,16 +162,21 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
const [isPickingFeaturedModel, setIsPickingFeaturedModel] = useState(initialMode === "featured-models")
|
||||
const [featuredModelIndex, setFeaturedModelIndex] = useState(0)
|
||||
const featuredModels = useClineFeaturedModels()
|
||||
const [isPickingProvider, setIsPickingProvider] = useState(false)
|
||||
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
|
||||
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
|
||||
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
|
||||
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
|
||||
const [isShowingOcaEmployeeCheck, setIsShowingOcaEmployeeCheck] = useState(false)
|
||||
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
|
||||
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
|
||||
const [apiKeyValue, setApiKeyValue] = useState("")
|
||||
const [editValue, setEditValue] = useState("")
|
||||
|
||||
// Bedrock custom ARN flow state
|
||||
const [isBedrockCustomFlow, setIsBedrockCustomFlow] = useState(false)
|
||||
|
||||
// Settings state - single object for feature toggles
|
||||
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {}
|
||||
@@ -165,6 +201,12 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [planThinkingEnabled, setPlanThinkingEnabled] = useState<boolean>(
|
||||
() => (stateManager.getGlobalSettingsKey("planModeThinkingBudgetTokens") ?? 0) > 0,
|
||||
)
|
||||
const [actReasoningEffort, setActReasoningEffort] = useState<OpenaiReasoningEffort>(() =>
|
||||
normalizeReasoningEffort(stateManager.getGlobalSettingsKey("actModeReasoningEffort")),
|
||||
)
|
||||
const [planReasoningEffort, setPlanReasoningEffort] = useState<OpenaiReasoningEffort>(() =>
|
||||
normalizeReasoningEffort(stateManager.getGlobalSettingsKey("planModeReasoningEffort")),
|
||||
)
|
||||
|
||||
// Auto-approve settings (complex nested object)
|
||||
const [autoApproveSettings, setAutoApproveSettings] = useState<AutoApprovalSettings>(() => {
|
||||
@@ -203,6 +245,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
// OCA auth hook
|
||||
const handleOcaAuthSuccess = useCallback(async () => {
|
||||
await applyProviderConfig({ providerId: "oca", controller })
|
||||
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
|
||||
await refreshOcaModels(controller!, StringRequest.create({ value: "" }))
|
||||
setProvider("oca")
|
||||
refreshModelIds()
|
||||
}, [controller, refreshModelIds])
|
||||
@@ -395,9 +439,12 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
|
||||
// Build items list based on current tab
|
||||
const items: ListItem[] = useMemo(() => {
|
||||
// OpenAI Native, Codex, and GPT models don't support thinking budget (they use reasoning effort)
|
||||
const isGptModel = actModelId?.toLowerCase().includes("gpt") || planModelId?.toLowerCase().includes("gpt")
|
||||
const showThinkingOption = provider !== "openai-native" && provider !== "openai-codex" && !isGptModel
|
||||
// Some providers/models expose reasoning effort instead of thinking budget controls.
|
||||
const providerUsesReasoningEffort = provider === "openai-native" || provider === "openai-codex"
|
||||
const showActReasoningEffort = supportsReasoningEffortForModel(actModelId || "")
|
||||
const showPlanReasoningEffort = supportsReasoningEffortForModel(planModelId || "")
|
||||
const showActThinkingOption = !providerUsesReasoningEffort && !showActReasoningEffort
|
||||
const showPlanThinkingOption = !providerUsesReasoningEffort && !showPlanReasoningEffort
|
||||
|
||||
switch (currentTab) {
|
||||
case "api":
|
||||
@@ -421,7 +468,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
...(showThinkingOption
|
||||
...(showActThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
@@ -431,6 +478,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showActReasoningEffort
|
||||
? [
|
||||
{
|
||||
key: "actReasoningEffort",
|
||||
label: "Reasoning effort",
|
||||
type: "cycle" as const,
|
||||
value: actReasoningEffort,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: "planHeader", label: "Plan Mode", type: "header" as const, value: "" },
|
||||
{
|
||||
key: "planModelId",
|
||||
@@ -438,7 +495,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: planModelId || "not set",
|
||||
},
|
||||
...(showThinkingOption
|
||||
...(showPlanThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "planThinkingEnabled",
|
||||
@@ -448,6 +505,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showPlanReasoningEffort
|
||||
? [
|
||||
{
|
||||
key: "planReasoningEffort",
|
||||
label: "Reasoning effort",
|
||||
type: "cycle" as const,
|
||||
value: planReasoningEffort,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: "spacer1", label: "", type: "spacer" as const, value: "" },
|
||||
]
|
||||
: [
|
||||
@@ -457,7 +524,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
...(showThinkingOption
|
||||
...(showActThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
@@ -467,6 +534,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showActReasoningEffort
|
||||
? [
|
||||
{
|
||||
key: "actReasoningEffort",
|
||||
label: "Reasoning effort",
|
||||
type: "cycle" as const,
|
||||
value: actReasoningEffort,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
{
|
||||
key: "separateModels",
|
||||
@@ -629,6 +706,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
separateModels,
|
||||
actThinkingEnabled,
|
||||
planThinkingEnabled,
|
||||
actReasoningEffort,
|
||||
planReasoningEffort,
|
||||
autoApproveSettings,
|
||||
features,
|
||||
preferredLanguage,
|
||||
@@ -662,6 +741,33 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
}
|
||||
}, [items.length, selectedIndex])
|
||||
|
||||
const rebuildTaskApi = useCallback(() => {
|
||||
if (!controller?.task) {
|
||||
return
|
||||
}
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}, [controller, stateManager])
|
||||
|
||||
const setReasoningEffortForMode = useCallback(
|
||||
(mode: "act" | "plan", effort: OpenaiReasoningEffort) => {
|
||||
if (mode === "act") {
|
||||
setActReasoningEffort(effort)
|
||||
stateManager.setGlobalState("actModeReasoningEffort", effort)
|
||||
if (!separateModels) {
|
||||
setPlanReasoningEffort(effort)
|
||||
stateManager.setGlobalState("planModeReasoningEffort", effort)
|
||||
}
|
||||
} else {
|
||||
setPlanReasoningEffort(effort)
|
||||
stateManager.setGlobalState("planModeReasoningEffort", effort)
|
||||
}
|
||||
rebuildTaskApi()
|
||||
},
|
||||
[separateModels, rebuildTaskApi, stateManager],
|
||||
)
|
||||
|
||||
// Handle toggle/edit for selected item
|
||||
const handleAction = useCallback(() => {
|
||||
const item = items[selectedIndex]
|
||||
@@ -685,6 +791,15 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "cycle") {
|
||||
const targetMode = item.key === "actReasoningEffort" ? "act" : item.key === "planReasoningEffort" ? "plan" : undefined
|
||||
if (targetMode) {
|
||||
const currentEffort = targetMode === "act" ? actReasoningEffort : planReasoningEffort
|
||||
setReasoningEffortForMode(targetMode, nextReasoningEffort(currentEffort))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "editable") {
|
||||
// For provider field, use the provider picker
|
||||
if (item.key === "provider") {
|
||||
@@ -742,7 +857,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const actModel = stateManager.getGlobalSettingsKey(actKey)
|
||||
if (planKey) stateManager.setGlobalState(planKey, actModel)
|
||||
}
|
||||
const actThinkingBudget = stateManager.getGlobalSettingsKey("actModeThinkingBudgetTokens") ?? 0
|
||||
stateManager.setGlobalState("planModeThinkingBudgetTokens", actThinkingBudget)
|
||||
setPlanThinkingEnabled(actThinkingBudget > 0)
|
||||
|
||||
const actEffort = normalizeReasoningEffort(stateManager.getGlobalSettingsKey("actModeReasoningEffort"))
|
||||
stateManager.setGlobalState("planModeReasoningEffort", actEffort)
|
||||
setPlanReasoningEffort(actEffort)
|
||||
}
|
||||
|
||||
rebuildTaskApi()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -750,23 +874,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
if (item.key === "actThinkingEnabled") {
|
||||
setActThinkingEnabled(newValue)
|
||||
stateManager.setGlobalState("actModeThinkingBudgetTokens", newValue ? 1024 : 0)
|
||||
// Rebuild API handler to apply thinking budget change
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
if (!separateModels) {
|
||||
setPlanThinkingEnabled(newValue)
|
||||
stateManager.setGlobalState("planModeThinkingBudgetTokens", newValue ? 1024 : 0)
|
||||
}
|
||||
// Rebuild API handler to apply thinking budget change
|
||||
rebuildTaskApi()
|
||||
return
|
||||
}
|
||||
if (item.key === "planThinkingEnabled") {
|
||||
setPlanThinkingEnabled(newValue)
|
||||
stateManager.setGlobalState("planModeThinkingBudgetTokens", newValue ? 1024 : 0)
|
||||
// Rebuild API handler to apply thinking budget change
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
rebuildTaskApi()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -823,12 +943,63 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
handleClineLogin,
|
||||
handleClineLogout,
|
||||
accountOrganizations,
|
||||
separateModels,
|
||||
actReasoningEffort,
|
||||
planReasoningEffort,
|
||||
rebuildTaskApi,
|
||||
setReasoningEffortForMode,
|
||||
])
|
||||
|
||||
// Handle completion of the Bedrock custom ARN flow (ARN + base model selected)
|
||||
const handleBedrockCustomFlowComplete = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
|
||||
// Build a minimal BedrockConfig from current state for applyBedrockConfig
|
||||
const bedrockConfig: BedrockConfig = {
|
||||
awsRegion: apiConfig.awsRegion ?? "us-east-1",
|
||||
awsAuthentication: apiConfig.awsUseProfile ? "profile" : "credentials",
|
||||
awsUseCrossRegionInference: Boolean(apiConfig.awsUseCrossRegionInference),
|
||||
}
|
||||
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
// Flush pending state to ensure everything is persisted
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler if there's an active task
|
||||
rebuildTaskApi()
|
||||
|
||||
refreshModelIds()
|
||||
setIsBedrockCustomFlow(false)
|
||||
setPickingModelKey(null)
|
||||
|
||||
// If opened from /models command, close the entire settings panel
|
||||
if (initialMode) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, stateManager, controller, rebuildTaskApi, refreshModelIds, initialMode, onClose],
|
||||
)
|
||||
|
||||
// Handle model selection from picker
|
||||
const handleModelSelect = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (modelId === CUSTOM_MODEL_ID && provider === "bedrock") {
|
||||
setIsPickingModel(false)
|
||||
setIsBedrockCustomFlow(true)
|
||||
return
|
||||
}
|
||||
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
@@ -889,7 +1060,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose],
|
||||
[pickingModelKey, separateModels, stateManager, controller, provider, refreshModelIds, initialMode, onClose],
|
||||
)
|
||||
|
||||
// Handle language selection from picker
|
||||
@@ -965,8 +1136,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setProvider("oca")
|
||||
refreshModelIds()
|
||||
} else {
|
||||
// Not logged in - trigger OAuth
|
||||
startOcaAuth()
|
||||
// Not logged in - show employee check before auth
|
||||
setIsShowingOcaEmployeeCheck(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1123,7 +1294,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
|
||||
// Featured model picker mode (Cline provider)
|
||||
if (isPickingFeaturedModel) {
|
||||
const maxIndex = getFeaturedModelMaxIndex()
|
||||
const maxIndex = getFeaturedModelMaxIndex(featuredModels)
|
||||
|
||||
if (key.escape) {
|
||||
setIsPickingFeaturedModel(false)
|
||||
@@ -1137,12 +1308,12 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
} else if (key.downArrow) {
|
||||
setFeaturedModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (isBrowseAllSelected(featuredModelIndex)) {
|
||||
if (isBrowseAllSelected(featuredModelIndex, featuredModels)) {
|
||||
// Switch to full ModelPicker
|
||||
setIsPickingFeaturedModel(false)
|
||||
setIsPickingModel(true)
|
||||
} else {
|
||||
const selectedModel = getFeaturedModelAtIndex(featuredModelIndex)
|
||||
const selectedModel = getFeaturedModelAtIndex(featuredModelIndex, featuredModels)
|
||||
if (selectedModel && pickingModelKey) {
|
||||
handleModelSelect(selectedModel.id)
|
||||
setIsPickingFeaturedModel(false)
|
||||
@@ -1213,6 +1384,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Bedrock custom flow - input handled by BedrockCustomModelFlow component
|
||||
if (isBedrockCustomFlow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (key.escape) {
|
||||
setIsEditing(false)
|
||||
@@ -1257,7 +1433,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
|
||||
)
|
||||
|
||||
// Render content
|
||||
@@ -1348,6 +1524,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const label = pickingModelKey === "actModelId" ? "Model ID (Act)" : "Model ID (Plan)"
|
||||
return (
|
||||
<FeaturedModelPicker
|
||||
featuredModels={featuredModels}
|
||||
helpText="Arrows to navigate, Enter to select, Esc to cancel"
|
||||
selectedIndex={featuredModelIndex}
|
||||
title={`Select: ${label}`}
|
||||
@@ -1433,6 +1610,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (isShowingOcaEmployeeCheck) {
|
||||
return (
|
||||
<OcaEmployeeCheck
|
||||
isActive={isShowingOcaEmployeeCheck}
|
||||
onCancel={() => setIsShowingOcaEmployeeCheck(false)}
|
||||
onSignIn={() => {
|
||||
setIsShowingOcaEmployeeCheck(false)
|
||||
startOcaAuth()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isWaitingForOcaAuth) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -1452,6 +1642,20 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Bedrock custom model flow (ARN input + base model selection)
|
||||
if (isBedrockCustomFlow) {
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={isBedrockCustomFlow}
|
||||
onCancel={() => {
|
||||
setIsBedrockCustomFlow(false)
|
||||
setIsPickingModel(true)
|
||||
}}
|
||||
onComplete={handleBedrockCustomFlowComplete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Account tab - loading state
|
||||
if (currentTab === "account" && isAccountLoading) {
|
||||
return (
|
||||
@@ -1569,6 +1773,21 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (item.type === "cycle") {
|
||||
return (
|
||||
<Text key={item.key}>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}: </Text>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
{typeof item.value === "string" ? item.value : String(item.value)}
|
||||
</Text>
|
||||
{isSelected && <Text color="gray"> (Tab to cycle)</Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// Readonly or editable field
|
||||
return (
|
||||
<Text key={item.key}>
|
||||
@@ -1599,7 +1818,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
!!codexAuthError ||
|
||||
isPickingOrganization ||
|
||||
isWaitingForClineAuth ||
|
||||
isShowingOcaEmployeeCheck ||
|
||||
isWaitingForOcaAuth ||
|
||||
isBedrockCustomFlow ||
|
||||
isEditing
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Tests for SkillsPanelContent component
|
||||
*
|
||||
* Tests keyboard interactions and callbacks.
|
||||
* Rendering tests are limited due to ink-testing-library constraints with nested components.
|
||||
*/
|
||||
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Mock refreshSkills
|
||||
const mockRefreshSkills = vi.fn()
|
||||
vi.mock("@/core/controller/file/refreshSkills", () => ({
|
||||
refreshSkills: () => mockRefreshSkills(),
|
||||
}))
|
||||
|
||||
// Mock toggleSkill
|
||||
const mockToggleSkill = vi.fn()
|
||||
vi.mock("@/core/controller/file/toggleSkill", () => ({
|
||||
toggleSkill: (...args: unknown[]) => mockToggleSkill(...args),
|
||||
}))
|
||||
|
||||
// Mock child_process exec
|
||||
const mockExec = vi.fn()
|
||||
vi.mock("node:child_process", () => ({
|
||||
exec: (...args: unknown[]) => mockExec(...args),
|
||||
}))
|
||||
|
||||
// Mock StdinContext
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
useStdinContext: () => ({ isRawModeSupported: true }),
|
||||
}))
|
||||
|
||||
import { SkillsPanelContent } from "./SkillsPanelContent"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("SkillsPanelContent", () => {
|
||||
const mockController = {} as any
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUseSkill = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
controller: mockController,
|
||||
onClose: mockOnClose,
|
||||
onUseSkill: mockOnUseSkill,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [],
|
||||
localSkills: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard interactions", () => {
|
||||
it("should call onClose when Escape is pressed", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write("\x1B") // Escape
|
||||
await delay()
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call onUseSkill with skill path when Enter is pressed on a skill", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md")
|
||||
})
|
||||
|
||||
it("should call toggleSkill when Space is pressed on a skill", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write(" ") // Space
|
||||
await delay()
|
||||
|
||||
expect(mockToggleSkill).toHaveBeenCalledWith(
|
||||
mockController,
|
||||
expect.objectContaining({
|
||||
skillPath: "/test/path/SKILL.md",
|
||||
isGlobal: true,
|
||||
enabled: false, // toggled from true to false
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should open marketplace URL when Enter is pressed on marketplace item", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "skill", description: "desc", path: "/path", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate down to marketplace (past the one skill)
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
|
||||
// Should have called exec with open command
|
||||
expect(mockExec).toHaveBeenCalled()
|
||||
const execCall = mockExec.mock.calls[0][0]
|
||||
expect(execCall).toContain("https://skills.sh/")
|
||||
})
|
||||
|
||||
it("should navigate through skills with arrow keys", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [
|
||||
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
|
||||
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
|
||||
],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate down
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
await delay()
|
||||
|
||||
// Press Enter - should use second skill
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
|
||||
})
|
||||
|
||||
it("should navigate with vim keys (j/k)", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [
|
||||
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
|
||||
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
|
||||
],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate down with j
|
||||
stdin.write("j")
|
||||
await delay()
|
||||
|
||||
// Press Enter - should use second skill
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
|
||||
})
|
||||
|
||||
it("should revert optimistic toggle on failure", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed"))
|
||||
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write(" ") // Space to toggle
|
||||
await delay(100)
|
||||
|
||||
// toggleSkill was called with enabled: false (toggled from true)
|
||||
expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false }))
|
||||
const frame = lastFrame() || ""
|
||||
expect(frame).toContain("● test-skill")
|
||||
expect(frame).not.toContain("○ test-skill")
|
||||
})
|
||||
|
||||
it("should wrap navigation at list boundaries", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "only-skill", description: "Only", path: "/only", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate up from first item (should wrap to last - marketplace)
|
||||
stdin.write("\x1B[A") // Up arrow
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
|
||||
// Should have opened marketplace (wrapped to last item)
|
||||
expect(mockExec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill loading", () => {
|
||||
it("should call refreshSkills on mount", async () => {
|
||||
render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
expect(mockRefreshSkills).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Skills panel content for inline display in ChatView
|
||||
* Shows installed skills with toggle and use functionality
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshSkills } from "@/core/controller/file/refreshSkills"
|
||||
import { toggleSkill } from "@/core/controller/file/toggleSkill"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SkillsPanelContentProps {
|
||||
controller: Controller
|
||||
onClose: () => void
|
||||
onUseSkill: (skillPath: string) => void
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 8
|
||||
|
||||
export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controller, onClose, onUseSkill }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
|
||||
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Load skills on mount
|
||||
useEffect(() => {
|
||||
const loadSkills = async () => {
|
||||
try {
|
||||
const skillsData = await refreshSkills(controller)
|
||||
setGlobalSkills(skillsData.globalSkills || [])
|
||||
setLocalSkills(skillsData.localSkills || [])
|
||||
} catch (_error) {
|
||||
// Skills loading failed, show empty state
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadSkills()
|
||||
}, [controller])
|
||||
|
||||
// Build flat list of skills with source info (global first, then local, alphabetical within each)
|
||||
const skillEntries = useMemo(() => {
|
||||
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
|
||||
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
|
||||
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
|
||||
return entries.sort((a, b) => {
|
||||
if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1
|
||||
return a.skill.name.localeCompare(b.skill.name)
|
||||
})
|
||||
}, [globalSkills, localSkills])
|
||||
|
||||
// Handle toggle
|
||||
const handleToggle = useCallback(async () => {
|
||||
const entry = skillEntries[selectedIndex]
|
||||
if (!entry) return
|
||||
|
||||
const newEnabled = !entry.skill.enabled
|
||||
const setter = entry.isGlobal ? setGlobalSkills : setLocalSkills
|
||||
const update = (enabled: boolean) =>
|
||||
setter((prev) => prev.map((s) => (s.path === entry.skill.path ? { ...s, enabled } : s)))
|
||||
|
||||
// Optimistic update
|
||||
update(newEnabled)
|
||||
|
||||
try {
|
||||
await toggleSkill(controller, {
|
||||
metadata: undefined,
|
||||
skillPath: entry.skill.path,
|
||||
isGlobal: entry.isGlobal,
|
||||
enabled: newEnabled,
|
||||
})
|
||||
} catch {
|
||||
// Revert on failure
|
||||
update(!newEnabled)
|
||||
}
|
||||
}, [controller, skillEntries, selectedIndex])
|
||||
|
||||
// Handle use skill (insert @ mention)
|
||||
const handleUse = useCallback(() => {
|
||||
const entry = skillEntries[selectedIndex]
|
||||
if (!entry) return
|
||||
onUseSkill(entry.skill.path)
|
||||
}, [skillEntries, selectedIndex, onUseSkill])
|
||||
|
||||
// Handle opening the marketplace URL
|
||||
const openMarketplace = useCallback(() => {
|
||||
const platform = os.platform()
|
||||
let command: string
|
||||
if (platform === "darwin") {
|
||||
command = `open "${SKILLS_MARKETPLACE_URL}"`
|
||||
} else if (platform === "win32") {
|
||||
command = `start "${SKILLS_MARKETPLACE_URL}"`
|
||||
} else {
|
||||
command = `xdg-open "${SKILLS_MARKETPLACE_URL}"`
|
||||
}
|
||||
exec(command, (err) => {
|
||||
if (err) {
|
||||
// Fallback: show URL in terminal if browser open fails
|
||||
console.error(`Visit: ${SKILLS_MARKETPLACE_URL}`)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Total items = skills + 1 for marketplace link
|
||||
const totalItems = skillEntries.length + 1
|
||||
const isMarketplaceSelected = selectedIndex === skillEntries.length
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (key.return) {
|
||||
if (isMarketplaceSelected) {
|
||||
openMarketplace()
|
||||
} else {
|
||||
handleUse()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (input === " " && !isMarketplaceSelected) {
|
||||
handleToggle()
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
// Scrolling window (includes marketplace row)
|
||||
const halfVisible = Math.floor(MAX_VISIBLE / 2)
|
||||
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, totalItems - MAX_VISIBLE))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Panel label="Skills">
|
||||
<Text color="gray">Loading skills...</Text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if marketplace row is in visible window
|
||||
const marketplaceIndex = skillEntries.length
|
||||
const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<Panel label="Skills">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{skillEntries.length === 0 ? (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="gray">No skills installed.</Text>
|
||||
<Text>
|
||||
Install skills with: <Text color="white">npx skills add owner/repo</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{skillEntries
|
||||
.slice(startIndex, Math.min(startIndex + MAX_VISIBLE, skillEntries.length))
|
||||
.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = skillEntries[actualIndex - 1]
|
||||
const showHeader = actualIndex === 0 || (prevEntry && prevEntry.isGlobal !== entry.isGlobal)
|
||||
|
||||
return (
|
||||
<React.Fragment key={entry.skill.path}>
|
||||
{showHeader && (
|
||||
<Box marginTop={actualIndex > 0 ? 1 : 0}>
|
||||
<Text bold color="gray">
|
||||
{entry.isGlobal ? "Global Skills:" : "Workspace Skills:"}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Marketplace link - selectable */}
|
||||
{showMarketplace && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={isMarketplaceSelected ? "cyan" : undefined}>
|
||||
{isMarketplaceSelected ? "❯ " : " "}
|
||||
<Text color={COLORS.primaryBlue}>Browse more skills at https://skills.sh/</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
↑/↓ Navigate • Enter {isMarketplaceSelected ? "Open" : "Use"}
|
||||
{!isMarketplaceSelected && " • Space Toggle"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { ClineAskUseSubagents, ClineMessage, ClineSaySubagentStatus } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
|
||||
interface SubagentMessageProps {
|
||||
message: ClineMessage
|
||||
isStreaming?: boolean
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
const TREE_PREFIX_WIDTH = 5
|
||||
const MIN_PROMPT_WIDTH = 20
|
||||
|
||||
const DotRow: React.FC<{ children: React.ReactNode; color?: string; flashing?: boolean }> = ({
|
||||
children,
|
||||
color,
|
||||
flashing = false,
|
||||
}) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2}>
|
||||
{flashing ? (
|
||||
<Text color={color}>
|
||||
<Spinner type="toggle8" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>⏺</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
function formatCompactTokens(tokens: number | undefined): string {
|
||||
const value = Number.isFinite(tokens) ? Math.max(0, tokens || 0) : 0
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
.format(value)
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function formatCompactCost(cost: number | undefined): string {
|
||||
const value = Number.isFinite(cost) ? Math.max(0, cost || 0) : 0
|
||||
const maximumFractionDigits = value >= 0.01 ? 2 : 4
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatSubagentStatsValues(
|
||||
toolCalls: number | undefined,
|
||||
contextTokens: number | undefined,
|
||||
totalCost: number | undefined,
|
||||
latestToolCall?: string,
|
||||
) {
|
||||
const safeToolCalls = Number.isFinite(toolCalls) ? Math.max(0, toolCalls || 0) : 0
|
||||
const toolUses = safeToolCalls === 1 ? "tool use" : "tool uses"
|
||||
const tokensUsed = formatCompactTokens(contextTokens || 0)
|
||||
const formattedCost = formatCompactCost(totalCost || 0)
|
||||
const stats = `${safeToolCalls} ${toolUses} · ${tokensUsed} tokens · ${formattedCost}`
|
||||
const latestTool = latestToolCall?.trim()
|
||||
return latestTool ? `${latestTool} · ${stats}` : stats
|
||||
}
|
||||
|
||||
function wrapPrompt(text: string, width: number): string[] {
|
||||
if (!text) {
|
||||
return [""]
|
||||
}
|
||||
|
||||
const normalizedWidth = Math.max(1, width)
|
||||
const wrappedLines: string[] = []
|
||||
const paragraphs = text.split("\n")
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
const words = paragraph.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) {
|
||||
wrappedLines.push("")
|
||||
continue
|
||||
}
|
||||
|
||||
let line = ""
|
||||
for (const word of words) {
|
||||
if (!line) {
|
||||
if (word.length <= normalizedWidth) {
|
||||
line = word
|
||||
continue
|
||||
}
|
||||
|
||||
let remaining = word
|
||||
while (remaining.length > normalizedWidth) {
|
||||
wrappedLines.push(remaining.slice(0, normalizedWidth))
|
||||
remaining = remaining.slice(normalizedWidth)
|
||||
}
|
||||
line = remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.length + 1 + word.length <= normalizedWidth) {
|
||||
line = `${line} ${word}`
|
||||
continue
|
||||
}
|
||||
|
||||
wrappedLines.push(line)
|
||||
|
||||
if (word.length <= normalizedWidth) {
|
||||
line = word
|
||||
continue
|
||||
}
|
||||
|
||||
let remaining = word
|
||||
while (remaining.length > normalizedWidth) {
|
||||
wrappedLines.push(remaining.slice(0, normalizedWidth))
|
||||
remaining = remaining.slice(normalizedWidth)
|
||||
}
|
||||
line = remaining
|
||||
}
|
||||
|
||||
if (line) {
|
||||
wrappedLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedLines.length > 0 ? wrappedLines : [text]
|
||||
}
|
||||
|
||||
const TreePromptRow: React.FC<{
|
||||
prefix: React.ReactNode
|
||||
continuationPrefix: string
|
||||
prompt: string
|
||||
promptWidth: number
|
||||
color?: string
|
||||
}> = ({ prefix, continuationPrefix, prompt, promptWidth, color }) => {
|
||||
const lines = wrapPrompt(prompt, promptWidth)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{lines.map((line, index) => (
|
||||
<Box flexDirection="row" key={`${line}-${index}`} width="100%">
|
||||
<Box flexShrink={0} width={TREE_PREFIX_WIDTH}>
|
||||
{index === 0 ? prefix : <Text color="gray">{continuationPrefix}</Text>}
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color}>{line}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const TreeStatsRow: React.FC<{ prefix: string; stats: string }> = ({ prefix, stats }) => (
|
||||
<Box flexDirection="row" width="100%">
|
||||
<Box flexShrink={0} width={TREE_PREFIX_WIDTH}>
|
||||
<Text color="gray">{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color="gray">⎿ {stats}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode, isStreaming }) => {
|
||||
const { type, ask, say, text, partial } = message
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
const { columns } = useTerminalSize()
|
||||
const promptWidth = Math.max(MIN_PROMPT_WIDTH, columns - 2 - TREE_PREFIX_WIDTH)
|
||||
|
||||
if ((type === "ask" && ask === "use_subagents") || say === "use_subagents") {
|
||||
const parsed = text
|
||||
? jsonParseSafe<ClineAskUseSubagents>(text, {
|
||||
prompts: [],
|
||||
})
|
||||
: { prompts: [] }
|
||||
|
||||
const prompts = (parsed.prompts || []).map((prompt) => prompt?.trim()).filter(Boolean)
|
||||
if (prompts.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text color={toolColor}>Cline wants to run subagents:</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const singular = prompts.length === 1
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>{singular ? "Cline wants to run a subagent:" : "Cline wants to run subagents:"}</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{prompts.map((prompt, index) => {
|
||||
const isLastPrompt = index === prompts.length - 1
|
||||
const branch = isLastPrompt ? "└─" : "├─"
|
||||
const continuationPrefix = isLastPrompt ? " " : "│ "
|
||||
const shouldShowPromptStats = partial !== true || !isLastPrompt
|
||||
return (
|
||||
<Box flexDirection="column" key={`${prompt}-${index}`}>
|
||||
<TreePromptRow
|
||||
color={toolColor}
|
||||
continuationPrefix={continuationPrefix}
|
||||
prefix={<Text color={toolColor}>{`${branch} `}</Text>}
|
||||
prompt={prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
{shouldShowPromptStats && (
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(undefined, undefined, undefined)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (say === "subagent" && text) {
|
||||
const parsed = jsonParseSafe<ClineSaySubagentStatus>(text, {
|
||||
status: "running",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
toolCalls: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
contextWindow: 0,
|
||||
maxContextTokens: 0,
|
||||
maxContextUsagePercentage: 0,
|
||||
items: [],
|
||||
})
|
||||
|
||||
const items = parsed.items || []
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>
|
||||
{items.length === 1 ? "Cline is running a subagent:" : "Cline is running subagents:"}
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{items.map((entry, index) => {
|
||||
const isLastEntry = index === items.length - 1
|
||||
const branch = isLastEntry ? "└─" : "├─"
|
||||
const continuationPrefix = isLastEntry ? " " : "│ "
|
||||
const key = `${entry.index}-${index}`
|
||||
const shouldShowStats = true
|
||||
|
||||
if (entry.status === "completed") {
|
||||
return (
|
||||
<Box flexDirection="column" key={key}>
|
||||
<TreePromptRow
|
||||
color="green"
|
||||
continuationPrefix={continuationPrefix}
|
||||
prefix={
|
||||
<Box flexDirection="row">
|
||||
<Text color="gray">{`${branch} `}</Text>
|
||||
<Text color="green">✓</Text>
|
||||
</Box>
|
||||
}
|
||||
prompt={entry.prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(
|
||||
entry.toolCalls,
|
||||
entry.contextTokens,
|
||||
entry.totalCost,
|
||||
entry.latestToolCall,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.status === "failed") {
|
||||
return (
|
||||
<Box flexDirection="column" key={key}>
|
||||
<TreePromptRow
|
||||
color="red"
|
||||
continuationPrefix={continuationPrefix}
|
||||
prefix={
|
||||
<Box flexDirection="row">
|
||||
<Text color="gray">{`${branch} `}</Text>
|
||||
<Text color="red">✗</Text>
|
||||
</Box>
|
||||
}
|
||||
prompt={entry.prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(
|
||||
entry.toolCalls,
|
||||
entry.contextTokens,
|
||||
entry.totalCost,
|
||||
entry.latestToolCall,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={key}>
|
||||
<TreePromptRow
|
||||
color={toolColor}
|
||||
continuationPrefix={continuationPrefix}
|
||||
prefix={
|
||||
<Box flexDirection="row">
|
||||
<Text color="gray">{branch} </Text>
|
||||
{entry.status === "running" ? (
|
||||
<Text color={toolColor}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={toolColor}>•</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
prompt={entry.prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
{shouldShowStats && (
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(
|
||||
entry.toolCalls,
|
||||
entry.contextTokens,
|
||||
entry.totalCost,
|
||||
entry.latestToolCall,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getAllFeaturedModels, mapRecommendedModelsToFeaturedModels } from "./featured-models"
|
||||
|
||||
describe("featured models", () => {
|
||||
it("includes display names for all featured models", () => {
|
||||
const models = getAllFeaturedModels()
|
||||
|
||||
for (const model of models) {
|
||||
expect(model.name).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it("fills free model metadata from fallback when upstream payload is sparse", () => {
|
||||
const models = mapRecommendedModelsToFeaturedModels({
|
||||
recommended: [],
|
||||
free: [{ id: "trinity-large-preview:free", name: "trinity-large-preview:free", description: "", tags: [] }],
|
||||
})
|
||||
|
||||
expect(models.free[0]?.name).toBe("Arcee AI Trinity Large Preview")
|
||||
expect(models.free[0]?.description).toBe("Arcee AI's advanced large preview model in the Trinity series")
|
||||
expect(models.free[0]?.labels).toContain("FREE")
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
* Featured models shown in the Cline model picker during onboarding
|
||||
* These are curated models that work well with Cline
|
||||
*/
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
|
||||
|
||||
export interface FeaturedModel {
|
||||
id: string
|
||||
@@ -10,55 +11,81 @@ export interface FeaturedModel {
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
export const FEATURED_MODELS = {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "State-of-the-art for complex coding",
|
||||
labels: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
description: "1M context window for large codebases",
|
||||
labels: ["TRENDING"],
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
free: [
|
||||
{
|
||||
id: "minimax/minimax-m2.1",
|
||||
name: "MiniMax M2.1",
|
||||
description: "Exceptional Multi-Programming Language Capabilities",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "moonshotai/kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
description: "State-of-the-art model topping benchmarks",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "kwaipilot/kat-coder-pro",
|
||||
name: "KAT Coder Pro",
|
||||
description: "Advanced agentic coding model",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "arcee-ai/trinity-large-preview:free",
|
||||
name: "Trinity Large Preview",
|
||||
description: "US built open source coding model",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
type RecommendedModelLike = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function getAllFeaturedModels(): FeaturedModel[] {
|
||||
return [...FEATURED_MODELS.recommended, ...FEATURED_MODELS.free]
|
||||
export interface FeaturedModelsByTier {
|
||||
recommended: FeaturedModel[]
|
||||
free: FeaturedModel[]
|
||||
}
|
||||
|
||||
interface RecommendedModelsByTier {
|
||||
recommended: RecommendedModelLike[]
|
||||
free: RecommendedModelLike[]
|
||||
}
|
||||
|
||||
function toFeaturedModel(model: RecommendedModelLike): FeaturedModel {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
labels: model.tags,
|
||||
}
|
||||
}
|
||||
|
||||
function getModelIdSuffix(id: string): string {
|
||||
const lastSlashIndex = id.lastIndexOf("/")
|
||||
return lastSlashIndex >= 0 ? id.slice(lastSlashIndex + 1) : id
|
||||
}
|
||||
|
||||
function findFallbackFeaturedModelById(models: FeaturedModel[], id: string): FeaturedModel | undefined {
|
||||
const idSuffix = getModelIdSuffix(id)
|
||||
return models.find((model) => model.id === id || getModelIdSuffix(model.id) === idSuffix)
|
||||
}
|
||||
|
||||
function mapRecommendedModelToFeaturedModelWithFallback(
|
||||
model: RecommendedModelLike,
|
||||
fallbackModels: FeaturedModel[],
|
||||
defaultLabels: string[] = [],
|
||||
): FeaturedModel {
|
||||
const fallbackModel = findFallbackFeaturedModelById(fallbackModels, model.id)
|
||||
const upstreamNameLooksLikeFallback = model.name === model.id || model.name.trim().length === 0
|
||||
const name = upstreamNameLooksLikeFallback ? (fallbackModel?.name ?? model.name) : model.name
|
||||
const description = model.description.trim().length > 0 ? model.description : (fallbackModel?.description ?? "")
|
||||
const labels = model.tags.length > 0 ? model.tags : (fallbackModel?.labels ?? defaultLabels)
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
name,
|
||||
description,
|
||||
labels,
|
||||
}
|
||||
}
|
||||
|
||||
export const FEATURED_MODELS: FeaturedModelsByTier = {
|
||||
recommended: CLINE_RECOMMENDED_MODELS_FALLBACK.recommended.map(toFeaturedModel),
|
||||
free: CLINE_RECOMMENDED_MODELS_FALLBACK.free.map(toFeaturedModel),
|
||||
}
|
||||
|
||||
export function getAllFeaturedModels(modelsByTier: FeaturedModelsByTier = FEATURED_MODELS): FeaturedModel[] {
|
||||
return [...modelsByTier.recommended, ...modelsByTier.free]
|
||||
}
|
||||
|
||||
export function mapRecommendedModelsToFeaturedModels(data: RecommendedModelsByTier): FeaturedModelsByTier {
|
||||
return {
|
||||
recommended: data.recommended.map((model) =>
|
||||
mapRecommendedModelToFeaturedModelWithFallback(model, FEATURED_MODELS.recommended),
|
||||
),
|
||||
free: data.free.map((model) => mapRecommendedModelToFeaturedModelWithFallback(model, FEATURED_MODELS.free, ["FREE"])),
|
||||
}
|
||||
}
|
||||
|
||||
export function withFeaturedModelFallback(modelsByTier: FeaturedModelsByTier): FeaturedModelsByTier {
|
||||
const recommended = modelsByTier.recommended.length > 0 ? modelsByTier.recommended : FEATURED_MODELS.recommended
|
||||
const free = modelsByTier.free.length > 0 ? modelsByTier.free : FEATURED_MODELS.free
|
||||
return { recommended, free }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Cline Library Exports
|
||||
*
|
||||
* This file exports the public API for programmatic use of Cline.
|
||||
* Use these classes and types to embed Cline into your applications.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ClineAgent } from "cline"
|
||||
*
|
||||
* const agent = new ClineAgent()
|
||||
* await agent.initialize({ clientCapabilities: {} })
|
||||
* const session = await agent.newSession({ cwd: process.cwd() })
|
||||
* ```
|
||||
* @module cline
|
||||
*/
|
||||
|
||||
export { ClineAgent } from "./agent/ClineAgent.js"
|
||||
export { ClineSessionEmitter } from "./agent/ClineSessionEmitter.js"
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
AcpSessionStatus,
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AudioContent,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
ClineAcpSession,
|
||||
ClineAgentCapabilities,
|
||||
ClineAgentInfo,
|
||||
ClineAgentOptions,
|
||||
ClinePermissionOption,
|
||||
ClineSessionEvents,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionHandler,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SessionUpdatePayload,
|
||||
SessionUpdateType,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolCallUpdate,
|
||||
ToolKind,
|
||||
TranslatedMessage,
|
||||
} from "./agent/public-types.js"
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
|
||||
import {
|
||||
type FeaturedModel,
|
||||
getAllFeaturedModels,
|
||||
mapRecommendedModelsToFeaturedModels,
|
||||
withFeaturedModelFallback,
|
||||
} from "../constants/featured-models"
|
||||
|
||||
export function useClineFeaturedModels(): FeaturedModel[] {
|
||||
const [featuredModels, setFeaturedModels] = useState<FeaturedModel[]>(() => getAllFeaturedModels())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const recommendedModels = await refreshClineRecommendedModels()
|
||||
const mappedModels = mapRecommendedModelsToFeaturedModels(recommendedModels)
|
||||
const modelsWithFallback = withFeaturedModelFallback(mappedModels)
|
||||
if (!cancelled) {
|
||||
setFeaturedModels(getAllFeaturedModels(modelsWithFallback))
|
||||
}
|
||||
} catch {
|
||||
// Keep local fallback models on error.
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return featuredModels
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* - Ctrl+A/E: start/end of line
|
||||
* - Ctrl+W: delete word backwards
|
||||
* - Ctrl+U: delete to start of line
|
||||
* - Ctrl+K: delete to end of line
|
||||
*
|
||||
* Note: Home/End keys are handled by useHomeEndKeys hook because Ink doesn't
|
||||
* expose them in useInput (it sets input='' for these keys).
|
||||
@@ -152,6 +153,14 @@ export function useTextInput(): UseTextInputReturn {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteToEnd = useCallback(() => {
|
||||
const pos = cursorRef.current
|
||||
if (pos < textRef.current.length) {
|
||||
setTextState((prev) => prev.slice(0, pos))
|
||||
// Cursor stays at same position (now at end of text)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor movement (internal, used by handlers)
|
||||
const moveToStart = useCallback(() => setCursorPosState(0), [])
|
||||
const moveToEnd = useCallback(() => setCursorPosState(textRef.current.length), [])
|
||||
@@ -190,6 +199,9 @@ export function useTextInput(): UseTextInputReturn {
|
||||
case "u": // Ctrl+U - delete to start
|
||||
deleteToStart()
|
||||
return true
|
||||
case "k": // Ctrl+K - delete to end
|
||||
deleteToEnd()
|
||||
return true
|
||||
case "w": // Ctrl+W - delete word backwards
|
||||
deleteWordBefore()
|
||||
return true
|
||||
@@ -197,7 +209,7 @@ export function useTextInput(): UseTextInputReturn {
|
||||
return false
|
||||
}
|
||||
},
|
||||
[moveToStart, moveToEnd, deleteToStart, deleteWordBefore],
|
||||
[moveToStart, moveToEnd, deleteToStart, deleteToEnd, deleteWordBefore],
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
+42
-2
@@ -30,7 +30,9 @@ describe("CLI Commands", () => {
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking")
|
||||
.option("--thinking [tokens]", "Enable extended thinking")
|
||||
.option("--reasoning-effort <effort>", "Reasoning effort")
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
@@ -67,7 +69,9 @@ describe("CLI Commands", () => {
|
||||
.option("-v, --verbose", "Verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking")
|
||||
.option("--thinking [tokens]", "Enable extended thinking")
|
||||
.option("--reasoning-effort <effort>", "Reasoning effort")
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -146,6 +150,27 @@ describe("CLI Commands", () => {
|
||||
expect(taskCmd.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking with token budget", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--thinking", "8000"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe("8000")
|
||||
})
|
||||
|
||||
it("should parse --reasoning-effort option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--reasoning-effort", "high"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
it("should parse --max-consecutive-mistakes option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--max-consecutive-mistakes", "999"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
@@ -281,6 +306,21 @@ describe("CLI Commands", () => {
|
||||
program.parse(["node", "cli", "--thinking"])
|
||||
expect(program.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking with token budget", () => {
|
||||
program.parse(["node", "cli", "--thinking", "4096"])
|
||||
expect(program.opts().thinking).toBe("4096")
|
||||
})
|
||||
|
||||
it("should parse --reasoning-effort option", () => {
|
||||
program.parse(["node", "cli", "--reasoning-effort", "medium"])
|
||||
expect(program.opts().reasoningEffort).toBe("medium")
|
||||
})
|
||||
|
||||
it("should parse --max-consecutive-mistakes option", () => {
|
||||
program.parse(["node", "cli", "--max-consecutive-mistakes", "7"])
|
||||
expect(program.opts().maxConsecutiveMistakes).toBe("7")
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
|
||||
+176
-152
@@ -8,21 +8,20 @@ import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { BannerService } from "@/services/banner/BannerService"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/PostHogClientProvider"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { getProviderModelIdKey } from "@/shared/storage"
|
||||
import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { runAcpMode } from "./acp/index.js"
|
||||
import { App } from "./components/App"
|
||||
@@ -30,19 +29,24 @@ import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { restoreConsole } from "./utils/console"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import { selectOutputMode } from "./utils/mode-selection"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
|
||||
import { readStdinIfPiped } from "./utils/piped"
|
||||
import { runPlainTextTask } from "./utils/plain-text-task"
|
||||
import { applyProviderConfig } from "./utils/provider-config"
|
||||
import { selectOutputMode } from "./utils/mode-selection"
|
||||
import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
|
||||
import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
|
||||
// CLI-only behavior: suppress console output unless verbose mode is enabled.
|
||||
// Kept explicit here so importing the library bundle does not mutate global console methods.
|
||||
suppressConsoleUnlessVerbose()
|
||||
|
||||
/**
|
||||
* Common options shared between runTask and resumeTask
|
||||
*/
|
||||
@@ -53,13 +57,75 @@ interface TaskOptions {
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
thinking?: boolean
|
||||
thinking?: boolean | string
|
||||
reasoningEffort?: string
|
||||
maxConsecutiveMistakes?: string
|
||||
yolo?: boolean
|
||||
doubleCheckCompletion?: boolean
|
||||
timeout?: string
|
||||
json?: boolean
|
||||
stdinWasPiped?: boolean
|
||||
}
|
||||
|
||||
let telemetryDisposed = false
|
||||
|
||||
async function disposeTelemetryServices(): Promise<void> {
|
||||
if (telemetryDisposed) {
|
||||
return
|
||||
}
|
||||
|
||||
telemetryDisposed = true
|
||||
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
|
||||
}
|
||||
|
||||
async function disposeCliContext(ctx: CliContext): Promise<void> {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeTelemetryServices()
|
||||
}
|
||||
|
||||
function setModeScopedState(currentMode: "act" | "plan", setter: (mode: "act" | "plan") => void): void {
|
||||
const stateManager = StateManager.get()
|
||||
setter(currentMode)
|
||||
|
||||
const separateModels = stateManager.getGlobalSettingsKey("planActSeparateModelsSetting") ?? false
|
||||
if (!separateModels) {
|
||||
const otherMode: "act" | "plan" = currentMode === "act" ? "plan" : "act"
|
||||
setter(otherMode)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value?: string): OpenaiReasoningEffort | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase()
|
||||
if (isOpenaiReasoningEffort(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
printWarning(
|
||||
`Invalid --reasoning-effort '${value}'. Using 'medium'. Valid values: ${OPENAI_REASONING_EFFORT_OPTIONS.join(", ")}.`,
|
||||
)
|
||||
return "medium"
|
||||
}
|
||||
|
||||
function normalizeMaxConsecutiveMistakes(value?: string): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
printWarning(`Invalid --max-consecutive-mistakes value '${value}'. Expected integer >= 1.`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply task-related options (mode, model, thinking, yolo) to StateManager.
|
||||
* Shared between runTask and resumeTask to avoid duplication.
|
||||
@@ -86,20 +152,57 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
telemetryService.captureHostEvent("model_flag", options.model)
|
||||
}
|
||||
|
||||
// Set thinking budget based on --thinking flag
|
||||
const thinkingBudget = options.thinking ? 1024 : 0
|
||||
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
|
||||
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
// Set thinking budget based on --thinking flag (boolean or number)
|
||||
let thinkingBudget = 0
|
||||
if (options.thinking) {
|
||||
if (typeof options.thinking === "string") {
|
||||
const parsed = Number.parseInt(options.thinking, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
printWarning(`Invalid --thinking value '${options.thinking}'. Using default 1024.`)
|
||||
thinkingBudget = 1024
|
||||
} else {
|
||||
thinkingBudget = parsed
|
||||
}
|
||||
} else {
|
||||
thinkingBudget = 1024
|
||||
}
|
||||
}
|
||||
const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
})
|
||||
if (options.thinking) {
|
||||
telemetryService.captureHostEvent("thinking_flag", "true")
|
||||
}
|
||||
|
||||
// Set yolo mode based on --yolo flag
|
||||
const reasoningEffort = normalizeReasoningEffort(options.reasoningEffort)
|
||||
if (reasoningEffort !== undefined) {
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const reasoningKey = mode === "act" ? "actModeReasoningEffort" : "planModeReasoningEffort"
|
||||
StateManager.get().setGlobalState(reasoningKey, reasoningEffort)
|
||||
})
|
||||
telemetryService.captureHostEvent("reasoning_effort_flag", reasoningEffort)
|
||||
}
|
||||
|
||||
const maxConsecutiveMistakes = normalizeMaxConsecutiveMistakes(options.maxConsecutiveMistakes)
|
||||
if (maxConsecutiveMistakes !== undefined) {
|
||||
StateManager.get().setGlobalState("maxConsecutiveMistakes", maxConsecutiveMistakes)
|
||||
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
|
||||
}
|
||||
|
||||
// Set yolo mode as a session-scoped override so AutoApprove picks it up,
|
||||
// but it is never persisted to disk (setSessionOverride never touches pendingGlobalState).
|
||||
if (options.yolo) {
|
||||
StateManager.get().setGlobalState("yoloModeToggled", true)
|
||||
StateManager.get().setSessionOverride("yoloModeToggled", true)
|
||||
telemetryService.captureHostEvent("yolo_flag", "true")
|
||||
}
|
||||
|
||||
// Set double-check completion based on flag
|
||||
if (options.doubleCheckCompletion) {
|
||||
StateManager.get().setGlobalState("doubleCheckCompletionEnabled", true)
|
||||
telemetryService.captureHostEvent("double_check_completion_flag", "true")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,9 +254,7 @@ async function runTaskInPlainTextMode(
|
||||
const hasAuth = await isAuthConfigured()
|
||||
if (!hasAuth) {
|
||||
printWarning("Not authenticated. Please run 'cline auth' first to configure your API credentials.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@@ -168,13 +269,11 @@ async function runTaskInPlainTextMode(
|
||||
imageDataUrls: taskConfig.imageDataUrls,
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
timeoutSeconds: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
timeoutSeconds: options.timeout ? Number.parseInt(options.timeout, 10) : undefined,
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
|
||||
// Ensure stdout is fully drained before exiting - critical for piping
|
||||
await drainStdout()
|
||||
@@ -186,9 +285,7 @@ async function runTaskInPlainTextMode(
|
||||
*/
|
||||
function createInkCleanup(ctx: CliContext, onTaskError?: () => boolean): () => Promise<void> {
|
||||
return async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
if (onTaskError?.()) {
|
||||
printWarning("Task ended with errors.")
|
||||
exit(1)
|
||||
@@ -248,10 +345,17 @@ function setupSignalHandlers() {
|
||||
if (task) {
|
||||
task.abortTask()
|
||||
}
|
||||
await activeContext.controller.stateManager.flushPendingState()
|
||||
await activeContext.controller.dispose()
|
||||
await disposeCliContext(activeContext)
|
||||
} else {
|
||||
// Best-effort flush of restored yolo state when no active context
|
||||
try {
|
||||
await StateManager.get().flushPendingState()
|
||||
} catch {
|
||||
// StateManager may not be initialized yet
|
||||
}
|
||||
await ErrorService.get().dispose()
|
||||
await disposeTelemetryServices()
|
||||
}
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
@@ -299,13 +403,22 @@ interface InitOptions {
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
await initializeDistinctId(extensionContext)
|
||||
// Set up output channel and Logger early so ClineEndpoint.initialize logs are captured
|
||||
const outputChannel = window.createOutputChannel("Cline CLI")
|
||||
const logToChannel = (message: string) => outputChannel.appendLine(message)
|
||||
|
||||
// Configure the shared Logging class early to capture all initialization logs
|
||||
Logger.subscribe(logToChannel)
|
||||
|
||||
await ClineEndpoint.initialize(EXTENSION_DIR)
|
||||
|
||||
// Auto-update check (after endpoints initialized, so we can detect bundled configs)
|
||||
autoUpdateOnStartup(CLI_VERSION)
|
||||
|
||||
// Initialize/reset session tracking for this CLI run
|
||||
Session.reset()
|
||||
@@ -314,11 +427,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
const outputChannel = window.createOutputChannel("Cline CLI")
|
||||
outputChannel.appendLine(
|
||||
`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}, Log dir: ${CLINE_CLI_DIR.log}`,
|
||||
)
|
||||
const logToChannel = (message: string) => outputChannel.appendLine(message)
|
||||
|
||||
HostProvider.initialize(
|
||||
() => new CliWebviewProvider(extensionContext as any),
|
||||
@@ -327,28 +438,20 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
() => new StandaloneTerminalManager(),
|
||||
createCliHostBridgeProvider(workspacePath),
|
||||
logToChannel,
|
||||
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
|
||||
async (path: string) => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl(path) : ""),
|
||||
getCliBinaryPath,
|
||||
EXTENSION_DIR,
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
await StateManager.initialize(extensionContext as any)
|
||||
await StateManager.initialize(storageContext)
|
||||
await ErrorService.initialize()
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(extensionContext)
|
||||
|
||||
// Configure the shared Logging class to use HostProvider's output channel
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg))
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
BannerService.initialize(webview.controller)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
telemetryService.captureHostEvent("cline_cli", "initialized")
|
||||
await telemetryService.captureExtensionActivated()
|
||||
await telemetryService.captureHostEvent("cline_cli", "initialized")
|
||||
|
||||
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
|
||||
activeContext = ctx
|
||||
@@ -430,8 +533,7 @@ async function runTask(prompt: string, options: TaskOptions & { images?: string[
|
||||
taskError = true
|
||||
},
|
||||
onWelcomeExit: () => {
|
||||
// User pressed Esc
|
||||
exit(0)
|
||||
// User pressed Esc; Ink exits and cleanup handles process exit.
|
||||
},
|
||||
}),
|
||||
createInkCleanup(ctx, () => taskError),
|
||||
@@ -447,8 +549,8 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
const taskHistory = StateManager.get().getGlobalStateKey("taskHistory") || []
|
||||
// Sort by timestamp (newest first) before pagination
|
||||
const sortedHistory = [...taskHistory].sort((a: any, b: any) => (b.ts || 0) - (a.ts || 0))
|
||||
const limit = typeof options.limit === "string" ? parseInt(options.limit, 10) : options.limit || 10
|
||||
const initialPage = typeof options.page === "string" ? parseInt(options.page, 10) : options.page || 1
|
||||
const limit = typeof options.limit === "string" ? Number.parseInt(options.limit, 10) : options.limit || 10
|
||||
const initialPage = typeof options.page === "string" ? Number.parseInt(options.page, 10) : options.page || 1
|
||||
const totalCount = sortedHistory.length
|
||||
const totalPages = Math.ceil(totalCount / limit)
|
||||
|
||||
@@ -456,9 +558,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
|
||||
if (sortedHistory.length === 0) {
|
||||
printInfo("No task history found.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
@@ -472,9 +572,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
@@ -503,9 +601,7 @@ async function showConfig(options: { config?: string }) {
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
@@ -581,17 +677,15 @@ async function runAuth(options: {
|
||||
baseurl: options.baseurl,
|
||||
})
|
||||
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
|
||||
if (!result.success) {
|
||||
printWarning(result.error || "Quick setup failed")
|
||||
telemetryService.captureHostEvent("auth", "error")
|
||||
await telemetryService.captureHostEvent("auth", "error")
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
telemetryService.captureHostEvent("auth", "completed")
|
||||
await telemetryService.captureHostEvent("auth", "completed")
|
||||
await disposeCliContext(ctx)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
@@ -605,7 +699,6 @@ async function runAuth(options: {
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
telemetryService.captureHostEvent("auth", "completed")
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
telemetryService.captureHostEvent("auth", "error")
|
||||
@@ -613,16 +706,10 @@ async function runAuth(options: {
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
await disposeCliContext(ctx)
|
||||
exit(authError ? 1 : 0)
|
||||
},
|
||||
)
|
||||
|
||||
if (authError) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup CLI commands
|
||||
@@ -641,13 +728,16 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--thinking [tokens]", "Enable extended thinking (default: 1024 tokens)")
|
||||
.option("--reasoning-effort <effort>", "Reasoning effort: none|low|medium|high|xhigh")
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.action((prompt, options) => {
|
||||
if (options.taskId) {
|
||||
@@ -674,9 +764,9 @@ program
|
||||
program
|
||||
.command("auth")
|
||||
.description("Authenticate a provider and configure what model is used")
|
||||
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic)")
|
||||
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic, moonshot)")
|
||||
.option("-k, --apikey <key>", "API key for the provider")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
|
||||
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -705,68 +795,6 @@ devCommand
|
||||
await openExternal(CLI_LOG_FILE)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user has completed onboarding (has any provider configured).
|
||||
*
|
||||
* Uses `welcomeViewCompleted` as the single source of truth, matching the VS Code extension's approach.
|
||||
* If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials
|
||||
* and sets the flag accordingly.
|
||||
*/
|
||||
async function isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check welcomeViewCompleted first - this is the single source of truth
|
||||
const welcomeViewCompleted = stateManager.getGlobalStateKey("welcomeViewCompleted")
|
||||
if (welcomeViewCompleted !== undefined) {
|
||||
return welcomeViewCompleted
|
||||
}
|
||||
|
||||
// welcomeViewCompleted is undefined - run migration logic to check if ANY provider has credentials
|
||||
// This mirrors the extension's migrateWelcomeViewCompleted behavior
|
||||
const hasAnyAuth = await checkAnyProviderConfigured()
|
||||
|
||||
// Set welcomeViewCompleted based on what we found
|
||||
stateManager.setGlobalState("welcomeViewCompleted", hasAnyAuth)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
return hasAnyAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ANY provider has valid credentials configured.
|
||||
* Used for migration when welcomeViewCompleted is undefined.
|
||||
*/
|
||||
async function checkAnyProviderConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const config = stateManager.getApiConfiguration() as Record<string, unknown>
|
||||
|
||||
// Check Cline account (stored as "cline:clineAccountId" in secrets, loaded into config)
|
||||
if (config["clineApiKey"] || config["cline:clineAccountId"]) return true
|
||||
|
||||
// Check OpenAI Codex OAuth (stored in SECRETS_KEYS, loaded into config)
|
||||
if (config["openai-codex-oauth-credentials"]) return true
|
||||
|
||||
// Check all BYO provider API keys (loaded into config from secrets)
|
||||
for (const [provider, keyField] of Object.entries(ProviderToApiKeyMap)) {
|
||||
// Skip cline - already checked above with the correct key
|
||||
if (provider === "cline") continue
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
if (config[field]) return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check provider-specific settings that indicate configuration
|
||||
// (for providers that don't require API keys like Bedrock with IAM, Ollama, LM Studio)
|
||||
if (config.awsRegion) return true
|
||||
if (config.vertexProjectId) return true
|
||||
if (config.ollamaBaseUrl) return true
|
||||
if (config.lmStudioBaseUrl) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a task exists in history
|
||||
* @returns The task history item if found, null otherwise
|
||||
@@ -788,9 +816,7 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
|
||||
if (!historyItem) {
|
||||
printWarning(`Task not found: ${taskId}`)
|
||||
printInfo("Use 'cline history' to see available tasks.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@@ -823,7 +849,7 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
|
||||
taskError = true
|
||||
},
|
||||
onWelcomeExit: () => {
|
||||
exit(0)
|
||||
// User pressed Esc; Ink exits and cleanup handles process exit.
|
||||
},
|
||||
}),
|
||||
createInkCleanup(ctx, () => taskError),
|
||||
@@ -850,16 +876,14 @@ async function showWelcome(options: { verbose?: boolean; cwd?: string; config?:
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onWelcomeExit: () => {
|
||||
exit(0)
|
||||
// User pressed Esc; Ink exits and cleanup handles process exit.
|
||||
},
|
||||
onError: () => {
|
||||
hadError = true
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(hadError ? 1 : 0)
|
||||
},
|
||||
)
|
||||
@@ -871,13 +895,16 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--thinking [tokens]", "Enable extended thinking (default: 1024 tokens)")
|
||||
.option("--reasoning-effort <effort>", "Reasoning effort: none|low|medium|high|xhigh")
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.action(async (prompt, options) => {
|
||||
@@ -947,8 +974,5 @@ program
|
||||
}
|
||||
})
|
||||
|
||||
// Background auto-update check (non-blocking)
|
||||
autoUpdateOnStartup(CLI_VERSION)
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("library import side effects", () => {
|
||||
it("importing library exports must not mutate console.log", async () => {
|
||||
const originalConsoleLog = console.log
|
||||
await import("./exports")
|
||||
expect(console.log).toBe(originalConsoleLog)
|
||||
}, 10000)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ProviderToApiKeyMap } from "@/shared/storage"
|
||||
|
||||
/**
|
||||
* Check if the user has completed onboarding (has any provider configured).
|
||||
*
|
||||
* Uses `welcomeViewCompleted` as the single source of truth, matching the VS Code extension's approach.
|
||||
* If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials
|
||||
* and sets the flag accordingly.
|
||||
*/
|
||||
export async function isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check welcomeViewCompleted first - this is the single source of truth
|
||||
const welcomeViewCompleted = stateManager.getGlobalStateKey("welcomeViewCompleted")
|
||||
if (welcomeViewCompleted !== undefined) {
|
||||
return welcomeViewCompleted
|
||||
}
|
||||
|
||||
// welcomeViewCompleted is undefined - run migration logic to check if ANY provider has credentials
|
||||
// This mirrors the extension's migrateWelcomeViewCompleted behavior
|
||||
const hasAnyAuth = await checkAnyProviderConfigured()
|
||||
|
||||
// Set welcomeViewCompleted based on what we found
|
||||
stateManager.setGlobalState("welcomeViewCompleted", hasAnyAuth)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
return hasAnyAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ANY provider has valid credentials configured.
|
||||
* Used for migration when welcomeViewCompleted is undefined.
|
||||
*/
|
||||
export async function checkAnyProviderConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const config = stateManager.getApiConfiguration() as Record<string, unknown>
|
||||
|
||||
// Check Cline account (stored as "cline:clineAccountId" in secrets, loaded into config)
|
||||
if (config["clineApiKey"] || config["cline:clineAccountId"]) return true
|
||||
|
||||
// Check OpenAI Codex OAuth (stored in SECRETS_KEYS, loaded into config)
|
||||
if (config["openai-codex-oauth-credentials"]) return true
|
||||
|
||||
// Check all BYO provider API keys (loaded into config from secrets)
|
||||
for (const [provider, keyField] of Object.entries(ProviderToApiKeyMap)) {
|
||||
// Skip cline - already checked above with the correct key
|
||||
if (provider === "cline") continue
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
if (config[field]) return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check provider-specific settings that indicate configuration
|
||||
// (for providers that don't require API keys like Bedrock with IAM, Ollama, LM Studio)
|
||||
if (config.awsRegion) return true
|
||||
if (config.vertexProjectId) return true
|
||||
if (config.ollamaBaseUrl) return true
|
||||
if (config.lmStudioBaseUrl) return true
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -12,11 +12,19 @@ export const originalConsoleWarn = console.warn.bind(console)
|
||||
export const originalConsoleInfo = console.info.bind(console)
|
||||
export const originalConsoleDebug = console.debug.bind(console)
|
||||
|
||||
// Check for verbose flag early (before commander parses)
|
||||
const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose")
|
||||
/**
|
||||
* Suppress console output unless verbose mode is enabled.
|
||||
*
|
||||
* This is intentionally opt-in and should only be called by the CLI entrypoint.
|
||||
* Library consumers should not have their global console methods mutated as a
|
||||
* side effect of importing the library bundle.
|
||||
*/
|
||||
export function suppressConsoleUnlessVerbose(argv: string[] = process.argv) {
|
||||
const isVerbose = argv.includes("-v") || argv.includes("--verbose")
|
||||
if (isVerbose) {
|
||||
return
|
||||
}
|
||||
|
||||
// Suppress console output unless verbose mode
|
||||
if (!isVerbose) {
|
||||
console.log = () => {}
|
||||
console.warn = () => {}
|
||||
console.error = () => {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { emitTaskStartedMessage } from "./task-start-output"
|
||||
|
||||
describe("emitTaskStartedMessage", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("writes structured task_started JSON to stdout in json mode", () => {
|
||||
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
|
||||
|
||||
emitTaskStartedMessage("task-123", true)
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith('{"type":"task_started","taskId":"task-123"}\n')
|
||||
expect(stderrWriteSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("writes human-readable task started line to stderr in non-json mode", () => {
|
||||
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
|
||||
|
||||
emitTaskStartedMessage("task-456", false)
|
||||
|
||||
expect(stderrWriteSpy).toHaveBeenCalledWith("Task started: task-456\n")
|
||||
expect(stdoutWriteSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import type { Controller } from "@/core/controller"
|
||||
import { getRequestRegistry } from "@/core/controller/grpc-handler"
|
||||
import { subscribeToState } from "@/core/controller/state/subscribeToState"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { emitTaskStartedMessage } from "./task-start-output"
|
||||
|
||||
export interface PlainTextTaskOptions {
|
||||
controller: Controller
|
||||
@@ -25,7 +26,7 @@ export interface PlainTextTaskOptions {
|
||||
imageDataUrls?: string[]
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
/** Timeout in seconds (default: 600 = 10 minutes) */
|
||||
/** Timeout in seconds (only applied when explicitly provided) */
|
||||
timeoutSeconds?: number
|
||||
/** Task ID to resume an existing task */
|
||||
taskId?: string
|
||||
@@ -52,6 +53,7 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
})
|
||||
|
||||
let hasError = false
|
||||
let hasEmittedTaskStarted = false
|
||||
// Track which messages have been processed (by timestamp)
|
||||
const processedMessages = new Map<number, string>()
|
||||
|
||||
@@ -62,6 +64,20 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
// results AFTER this time should trigger task completion.
|
||||
const completionCutoffTs = Date.now()
|
||||
|
||||
const emitTaskStarted = () => {
|
||||
if (hasEmittedTaskStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = controller.task?.taskId
|
||||
if (!taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
emitTaskStartedMessage(taskId, Boolean(jsonOutput))
|
||||
hasEmittedTaskStarted = true
|
||||
}
|
||||
|
||||
// Helper to process a message and track completion state
|
||||
const processMessage = (message: ClineMessage) => {
|
||||
const ts = message.ts || 0
|
||||
@@ -119,6 +135,7 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
if (options.taskId) {
|
||||
// Load the existing task
|
||||
await showTaskWithId(controller, StringRequest.create({ value: options.taskId }))
|
||||
emitTaskStarted()
|
||||
|
||||
// If a prompt was provided, send it as a message to the resumed task
|
||||
if (prompt && controller.task) {
|
||||
@@ -131,14 +148,19 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
} else if (prompt) {
|
||||
// Start a new task with the prompt
|
||||
await controller.initTask(prompt, imageDataUrls)
|
||||
emitTaskStarted()
|
||||
} else {
|
||||
throw new Error("Either taskId or prompt must be provided")
|
||||
}
|
||||
|
||||
// Normal mode: wait for task completion
|
||||
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
// Wait for task completion, with optional timeout only when explicitly configured
|
||||
if (options.timeoutSeconds) {
|
||||
const timeoutMs = options.timeoutSeconds * 1000
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
} else {
|
||||
await completionPromise
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error)
|
||||
if (jsonOutput) {
|
||||
|
||||
@@ -80,15 +80,18 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
|
||||
export interface ApplyBedrockConfigOptions {
|
||||
bedrockConfig: BedrockConfig
|
||||
modelId?: string
|
||||
customModelBaseId?: string // Base model ID for custom ARN/Inference Profile (for capability detection)
|
||||
controller?: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Bedrock provider configuration to state
|
||||
* Handles AWS-specific fields (authentication, region, credentials)
|
||||
* When customModelBaseId is provided, sets the custom model flags so the system
|
||||
* knows to use the ARN as the model ID and the base model for capability detection.
|
||||
*/
|
||||
export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Promise<void> {
|
||||
const { bedrockConfig, modelId, controller } = options
|
||||
const { bedrockConfig, modelId, customModelBaseId, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
@@ -108,6 +111,18 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
|
||||
if (planModelKey) config[planModelKey] = finalModelId
|
||||
}
|
||||
|
||||
// Handle custom model (Application Inference Profile ARN)
|
||||
if (customModelBaseId) {
|
||||
config.actModeAwsBedrockCustomSelected = true
|
||||
config.planModeAwsBedrockCustomSelected = true
|
||||
config.actModeAwsBedrockCustomModelBaseId = customModelBaseId
|
||||
config.planModeAwsBedrockCustomModelBaseId = customModelBaseId
|
||||
} else {
|
||||
// Ensure custom flags are cleared when using a standard model
|
||||
config.actModeAwsBedrockCustomSelected = false
|
||||
config.planModeAwsBedrockCustomSelected = false
|
||||
}
|
||||
|
||||
// Add optional AWS credentials
|
||||
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function emitTaskStartedMessage(taskId: string, jsonOutput: boolean): void {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ type: "task_started", taskId }) + "\n")
|
||||
return
|
||||
}
|
||||
|
||||
process.stderr.write(`Task started: ${taskId}\n`)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { exit } from "node:process"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { printInfo, printWarning } from "./display"
|
||||
|
||||
@@ -107,7 +108,7 @@ async function getLatestVersion(currentVersion: string): Promise<string | null>
|
||||
* process to install if a newer version is available.
|
||||
*
|
||||
* Supports npm, pnpm, yarn, and bun global installs.
|
||||
* Skipped for npx, local dev, and unknown installations.
|
||||
* Skipped for npx, local dev, unknown installations, and bundled enterprise packages.
|
||||
* Can be disabled with CLINE_NO_AUTO_UPDATE=1 environment variable.
|
||||
*/
|
||||
export function autoUpdateOnStartup(currentVersion: string): void {
|
||||
@@ -121,6 +122,11 @@ export function autoUpdateOnStartup(currentVersion: string): void {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if using bundled enterprise config (single source of truth)
|
||||
if (ClineEndpoint.isBundledConfig()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { updateCommand } = getInstallationInfo(currentVersion)
|
||||
if (!updateCommand) {
|
||||
return
|
||||
|
||||
+62
-93
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* VSCode context stub for CLI mode
|
||||
* Provides mock implementations of VSCode extension context
|
||||
* Provides mock implementations of VSCode extension context.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ClineFileStorage } from "@/shared/storage"
|
||||
import type { ClineMemento } from "@/shared/storage/ClineStorage"
|
||||
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
|
||||
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
/**
|
||||
* CLI-specific state overrides.
|
||||
@@ -30,33 +33,43 @@ const CLI_STATE_OVERRIDES: Record<string, any> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based Memento store with optional key overrides.
|
||||
* Implements VSCode's Memento interface using SyncJsonFileStorage.
|
||||
* Memento adapter that wraps a ClineFileStorage with optional key overrides.
|
||||
* Used for globalState where CLI needs to inject hardcoded overrides.
|
||||
*/
|
||||
class MementoStore extends ClineFileStorage {
|
||||
private overrides: Record<string, any>
|
||||
class MementoAdapter implements ClineMemento {
|
||||
constructor(
|
||||
private readonly store: ClineMemento,
|
||||
private readonly overrides: Record<string, any> = {},
|
||||
) {}
|
||||
|
||||
constructor(filePath: string, overrides: Record<string, any> = {}) {
|
||||
super(filePath, "MementoStore")
|
||||
this.overrides = overrides
|
||||
}
|
||||
|
||||
// VSCode Memento interface - override base class get() with overload support
|
||||
override get<T>(key: string): T | undefined
|
||||
override get<T>(key: string, defaultValue: T): T
|
||||
override get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
get<T>(key: string): T | undefined
|
||||
get<T>(key: string, defaultValue: T): T
|
||||
get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
if (key in this.overrides) {
|
||||
return this.overrides[key] as T
|
||||
}
|
||||
const value = super.get<T>(key)
|
||||
const value = this.store.get<T>(key)
|
||||
return value !== undefined ? value : defaultValue
|
||||
}
|
||||
|
||||
override async update(key: string, value: any): Promise<void> {
|
||||
if (key in this.overrides) {
|
||||
return
|
||||
update(key: string, value: any): Thenable<void> {
|
||||
return this.setBatch({ [key]: value })
|
||||
}
|
||||
|
||||
keys(): readonly string[] {
|
||||
return this.store.keys()
|
||||
}
|
||||
|
||||
setBatch(entries: Record<string, any>): Thenable<void> {
|
||||
// Filter out overridden keys and delegate to underlying store
|
||||
const filteredEntries: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!(key in this.overrides)) {
|
||||
filteredEntries[key] = value
|
||||
}
|
||||
}
|
||||
this.set(key, value)
|
||||
this.store.setBatch(filteredEntries)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
setKeysForSync(_keys: readonly string[]): void {
|
||||
@@ -64,84 +77,48 @@ class MementoStore extends ClineFileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based secret storage implementing VSCode's SecretStorage interface.
|
||||
* Uses sync storage internally but exposes async API for VSCode compatibility.
|
||||
*/
|
||||
class SecretStore {
|
||||
private storage: ClineFileStorage<string>
|
||||
private onDidChangeEmitter = {
|
||||
event: () => ({ dispose: () => {} }),
|
||||
fire: (_e: any) => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
|
||||
onDidChange = this.onDidChangeEmitter.event
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.storage = new ClineFileStorage<string>(filePath, "SecretStore")
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | undefined> {
|
||||
return Promise.resolve(this.storage.get(key))
|
||||
}
|
||||
|
||||
store(key: string, value: string): Promise<void> {
|
||||
this.storage.set(key, value)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
this.storage.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliContextConfig {
|
||||
clineDir?: string
|
||||
/** The workspace directory being worked in (for hashing into storage path) */
|
||||
/** The workspace directory being worked in (used to compute workspace storage hash) */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short hash of a string for use in directory names
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).substring(0, 8)
|
||||
}
|
||||
|
||||
export interface CliContextResult {
|
||||
extensionContext: ClineExtensionContext
|
||||
storageContext: StorageContext
|
||||
DATA_DIR: string
|
||||
EXTENSION_DIR: string
|
||||
WORKSPACE_STORAGE_DIR: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VSCode-like context for CLI mode
|
||||
* Initialize the VSCode-like context for CLI mode.
|
||||
*
|
||||
* Creates a shared StorageContext (the single source of truth for all storage)
|
||||
* and wraps it in a ClineExtensionContext shell for legacy APIs that still
|
||||
* expect the VSCode ExtensionContext shape.
|
||||
*/
|
||||
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
|
||||
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
|
||||
|
||||
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
|
||||
// where hash is derived from the workspace path to keep workspaces isolated
|
||||
const workspacePath = config.workspaceDir || process.cwd()
|
||||
const workspaceHash = hashString(workspacePath)
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
|
||||
// Create the shared StorageContext — this owns all ClineFileStorage instances.
|
||||
// CLI, JetBrains, and VSCode all share this same file-backed implementation.
|
||||
let storageContext = createStorageContext({
|
||||
clineDir: CLINE_DIR,
|
||||
workspacePath: config.workspaceDir || process.cwd(),
|
||||
workspaceStorageDir: process.env.WORKSPACE_STORAGE_DIR || undefined,
|
||||
})
|
||||
storageContext = {
|
||||
...storageContext,
|
||||
// Storage — delegates to storageContext stores (with CLI overrides for globalState)
|
||||
globalState: new MementoAdapter(storageContext.globalState, CLI_STATE_OVERRIDES),
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
const DATA_DIR = storageContext.dataDir
|
||||
const WORKSPACE_STORAGE_DIR = storageContext.workspaceStoragePath
|
||||
|
||||
// For CLI, extension dir is the root of the project (parent of cli)
|
||||
const EXTENSION_DIR = path.resolve(__dirname, "..", "..")
|
||||
// For CLI, extension dir is the package root (one level up from dist/)
|
||||
const EXTENSION_DIR = path.resolve(__dirname, "..")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: ClineExtensionContext["extension"] = {
|
||||
@@ -155,38 +132,30 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
|
||||
// Build the ClineExtensionContext shell. All storage delegates to storageContext —
|
||||
// there are NO separate ClineFileStorage instances here.
|
||||
const extensionContext: ClineExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
// Set up KV stores (globalState has CLI-specific overrides)
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
// Set up URIs
|
||||
// URIs / paths
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR,
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR,
|
||||
|
||||
// Logs
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR,
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR,
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [],
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection() as any,
|
||||
|
||||
// Workspace state
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return {
|
||||
extensionContext,
|
||||
storageContext,
|
||||
DATA_DIR,
|
||||
EXTENSION_DIR,
|
||||
WORKSPACE_STORAGE_DIR,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"declarationMap": false,
|
||||
"noCheck": true,
|
||||
"noResolve": true,
|
||||
"outDir": "dist/types"
|
||||
},
|
||||
"include": [
|
||||
"src/exports.ts",
|
||||
"src/agent/public-types.ts",
|
||||
"src/agent/ClineAgent.ts",
|
||||
"src/agent/ClineSessionEmitter.ts",
|
||||
"src/agent/types.ts",
|
||||
"src/agent/messageTranslator.ts",
|
||||
"src/agent/permissionHandler.ts"
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
vscode: path.resolve(__dirname, "src/vscode-shim.ts"),
|
||||
// Match tsconfig paths - baseUrl is parent directory
|
||||
"@": path.resolve(__dirname, "../src"),
|
||||
"@api": path.resolve(__dirname, "../src/core/api"),
|
||||
|
||||
@@ -202,15 +202,15 @@ If Cline can't access files or run commands:
|
||||
Learn about Cline CLI's core capabilities and use cases.
|
||||
</Card>
|
||||
|
||||
<Card title="Three Core Flows" icon="route" href="/cline-cli/three-core-flows">
|
||||
Master interactive mode, headless automation, and multi-instance workflows.
|
||||
<Card title="Headless Mode" icon="robot" href="/cline-cli/three-core-flows">
|
||||
Run Cline autonomously in scripts, CI/CD pipelines, and automated workflows.
|
||||
</Card>
|
||||
|
||||
<Card title="Skills" icon="graduation-cap" href="/features/skills">
|
||||
<Card title="Skills" icon="graduation-cap" href="/customization/skills">
|
||||
Understand how Cline's Skills work across all editors via ACP.
|
||||
</Card>
|
||||
|
||||
<Card title="Hooks" icon="link" href="/features/hooks/index">
|
||||
<Card title="Hooks" icon="link" href="/customization/hooks">
|
||||
Learn how to enforce policies with Hooks in any editor.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user