mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e073277d70 | |||
| 01547ba1f4 | |||
| 7f1632f09f | |||
| 5a2756d778 | |||
| 1b0ab3d01b | |||
| a41753c9a1 | |||
| 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 | |||
| 095ee24288 | |||
| 523dd9ef7d | |||
| 6d8fb8507b | |||
| edc93f35f1 | |||
| 401358374f | |||
| f8bcad16a5 | |||
| 26391c94e9 | |||
| 462438ece5 | |||
| 92521ed279 | |||
| 08aa81f798 | |||
| e025995177 | |||
| ee361ef3ae | |||
| c8ef342c19 | |||
| 7c8701799d | |||
| fbcf63ad71 | |||
| fffe626b3a | |||
| 2dccd6f6f6 | |||
| 39971128cb | |||
| b725490582 | |||
| 8f78645154 | |||
| 0ef1c0bf47 | |||
| 4c07df370b | |||
| f440f3a5dd | |||
| 3ce1ad3504 | |||
| 00bc38d4e0 | |||
| a1f2601fe0 | |||
| 8e3689a5d6 | |||
| a64f46a5f4 | |||
| 507483b2c3 | |||
| 42ce100143 | |||
| 7be4e6c6d3 | |||
| 09b91a1ea5 | |||
| 7127a2ffa7 | |||
| d6987d4578 | |||
| 7b59cbcb5c | |||
| 0e26ba46d0 | |||
| 3b313ae41f | |||
| 7e74f35f23 | |||
| 59c05f4b84 | |||
| 5d6424682f | |||
| b1a8db252a | |||
| 5ae47fb90b | |||
| 28c2697ae1 | |||
| cf01317885 | |||
| 7d5eebe192 | |||
| 91a3636356 | |||
| 5d02eea9cd | |||
| 45b2786dbf | |||
| 4924192b64 | |||
| 28c548b3ee | |||
| 9b9035ea4a | |||
| e4b39aeb22 | |||
| f7c54e964f | |||
| d116ac5dcf | |||
| ac22d5d81a | |||
| b57aefb5a1 | |||
| 11da3ee89e | |||
| e4e63912dd | |||
| f17d523b2e | |||
| edbba8b7f6 | |||
| 7b09999fdf | |||
| 01240744a2 | |||
| 2944416758 | |||
| b5b503dd50 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add /q command to quit CLI
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix decimal input crash in OpenAI Compatible price fields (#8129)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Always include the latest working directory path in system prompt.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: build complete handlers when upadting the api config
|
||||
@@ -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 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixed missing provider from list
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(skills): Make skills always enabled and remove feature toggle setting
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixed Favorite Icon / Star from getting clipped in the task history view
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix acp auth check so acp mode can be used with more providers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Update SambaNova Provider models list and add temperature for models
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)"
|
||||
'''
|
||||
+2
-3
@@ -1,3 +1,2 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
|
||||
@@ -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,70 @@
|
||||
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"
|
||||
npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1 --parallel
|
||||
|
||||
- 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,324 +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: |
|
||||
npx 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'
|
||||
@@ -9,7 +9,8 @@ on:
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
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,13 +73,18 @@ 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
|
||||
npm publish --tag latest --access public
|
||||
|
||||
- name: Tag release
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "v${{ steps.version.outputs.version }}-cli"
|
||||
git push origin "v${{ steps.version.outputs.version }}-cli"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'latest'"
|
||||
|
||||
@@ -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,30 +36,14 @@ 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
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-50
@@ -28,20 +28,10 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- 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') }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
@@ -73,20 +63,10 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- 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') }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
@@ -135,6 +115,11 @@ jobs:
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
- name: CLI Tests
|
||||
id: cli_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: cd cli && npm run test:run
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
# Only upload artifacts on Linux - We only need coverage from one OS
|
||||
@@ -156,28 +141,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- 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') }}
|
||||
|
||||
# Cache testing-platform dependencies
|
||||
- name: Cache testing-platform dependencies
|
||||
uses: actions/cache@v4
|
||||
id: testing-platform-cache
|
||||
with:
|
||||
path: testing-platform/node_modules
|
||||
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
testing-platform/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -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"
|
||||
|
||||
+126
-11
@@ -1,26 +1,141 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
- Fixed Opus 4.6 for bedrock provider
|
||||
|
||||
## [3.57.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Cline CLI 2.0 now available. Install with `npm install -g cline`
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix read file tool to support reading large files
|
||||
- Fix decimal input crash in OpenAI Compatible price fields (#8129)
|
||||
- Fix build complete handlers when updating the api config
|
||||
- Fixed missing provider from list
|
||||
- Fixed Favorite Icon / Star from getting clipped in the task history view
|
||||
|
||||
### Changed
|
||||
|
||||
- Make skills always enabled and remove feature toggle setting
|
||||
|
||||
## [3.56.0]
|
||||
|
||||
### 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]
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Cline Bot Inc.
|
||||
Copyright 2026 Cline Bot Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -148,4 +148,4 @@ Get the same Cline experience with enterprise-grade controls: SSO (SAML/OIDC), g
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2026 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
+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.
|
||||
+76
-67
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -28,19 +28,19 @@
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"useExhaustiveDependencies": "info",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "off",
|
||||
"noEmptyPattern": "info",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"useYield": "off",
|
||||
"useHookAtTopLevel": "info",
|
||||
"useYield": "info",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "off",
|
||||
"a11y": "info",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
@@ -51,35 +51,36 @@
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noNonNullAssertion": "info",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "off",
|
||||
"noUselessElse": "off"
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "off",
|
||||
"noAsyncPromiseExecutor": "info",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noExplicitAny": "info",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "info"
|
||||
"noAssignInExpressions": "info",
|
||||
"useIterableCallbackReturn": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noBannedTypes": "off",
|
||||
"useLiteralKeys": "off",
|
||||
"noUselessCatch": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noStaticOnlyClass": "off"
|
||||
"noUselessConstructor": "info",
|
||||
"useOptionalChain": "info",
|
||||
"noBannedTypes": "warn",
|
||||
"useLiteralKeys": "info",
|
||||
"noUselessCatch": "info",
|
||||
"noUselessSwitchCase": "info",
|
||||
"noStaticOnlyClass": "info"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
@@ -94,6 +95,11 @@
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
@@ -112,19 +118,21 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/dist-*",
|
||||
"!**/out",
|
||||
"!**/evals",
|
||||
"!**/playwright",
|
||||
"!**/test-results",
|
||||
"!**/node_modules",
|
||||
"!**/webview-ui/build",
|
||||
"!**/generated",
|
||||
"!**/proto",
|
||||
"!**/tests/specs"
|
||||
// explicitly force files to be ignored by the scanner with !!
|
||||
"!!**/dist",
|
||||
"!!**/dist-*",
|
||||
"!!**/out",
|
||||
"!!**/evals",
|
||||
"!!**/playwright",
|
||||
"!!**/test-results",
|
||||
"!!**/node_modules",
|
||||
"!!**/webview-ui/build",
|
||||
"!!**/generated",
|
||||
"!!**/proto",
|
||||
"!!**/tests/specs"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
@@ -134,14 +142,15 @@
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/hosts/vscode/**",
|
||||
"!**/test/**",
|
||||
"!**/*.test.ts",
|
||||
"!src/dev/**",
|
||||
"!src/extension.ts",
|
||||
"!src/integrations/git/commit-message-generator.ts",
|
||||
"!src/integrations/terminal/**",
|
||||
"!src/core/controller/ui/openWalkthrough.ts"
|
||||
"!!**/dist",
|
||||
"!!**/hosts/vscode/**",
|
||||
"!!**/test/**",
|
||||
"!!**/*.test.ts",
|
||||
"!!src/dev/**",
|
||||
"!!src/extension.ts",
|
||||
"!!src/integrations/git/commit-message-generator.ts",
|
||||
"!!src/integrations/terminal/**",
|
||||
"!!src/core/controller/ui/openWalkthrough.ts"
|
||||
],
|
||||
"plugins": [
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
@@ -154,37 +163,37 @@
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/esbuild.*",
|
||||
"!**/*.mts",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
"!**/cli/**",
|
||||
"!**/e2e/**",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.stories.ts",
|
||||
"!src/dev/**",
|
||||
"!**/*.mjs",
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**",
|
||||
"!!**/esbuild.*",
|
||||
"!!**/*.mts",
|
||||
"!!**/webview-ui/**",
|
||||
"!!**/evals/**",
|
||||
"!!**/standalone/**",
|
||||
"!!**/cli/**",
|
||||
"!!**/e2e/**",
|
||||
"!!**/test/**",
|
||||
"!!**/__tests__/**",
|
||||
"!!**/*.test.ts",
|
||||
"!!**/*.stories.ts",
|
||||
"!!src/dev/**",
|
||||
"!!**/*.mjs",
|
||||
"!!**/*.js",
|
||||
"!!**/scripts/**",
|
||||
"!!**/*.tsx",
|
||||
"!!**/testing-platform/**",
|
||||
// ACP mode must redirect console to stderr - this is intentional
|
||||
"!cli/src/acp/index.ts"
|
||||
"!!cli/src/acp/index.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
"!src/core/storage/state-migrations.ts",
|
||||
"!src/core/storage/FileContextTracker.ts",
|
||||
"!src/core/context/context-tracking/FileContextTracker.ts",
|
||||
"!src/common.ts",
|
||||
"!src/services/logging/distinctId.ts",
|
||||
"!src/core/storage/utils/state-helpers.ts",
|
||||
"!src/extension.ts"
|
||||
"!!src/core/storage/state-migrations.ts",
|
||||
"!!src/core/storage/FileContextTracker.ts",
|
||||
"!!src/core/context/context-tracking/FileContextTracker.ts",
|
||||
"!!src/common.ts",
|
||||
"!!src/services/logging/distinctId.ts",
|
||||
"!!src/core/storage/utils/state-helpers.ts",
|
||||
"!!src/extension.ts"
|
||||
],
|
||||
"plugins": [
|
||||
"src/dev/grit/use-cache-service.grit"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# cline
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
+43
-15
@@ -88,6 +88,10 @@ directory
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text
|
||||
.PP
|
||||
\f[B]\-T\f[R], \f[B]\-\-taskId\f[R] \f[I]id\f[R] : Resume an existing
|
||||
task by ID.
|
||||
The prompt argument becomes an optional follow\-up message.
|
||||
.SS history (alias: h)
|
||||
List task history with pagination.
|
||||
.PP
|
||||
@@ -121,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)
|
||||
@@ -179,6 +183,10 @@ the task
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text.
|
||||
Forces plain text mode.
|
||||
.PP
|
||||
\f[B]\-T\f[R], \f[B]\-\-taskId\f[R] \f[I]id\f[R] : Resume an existing
|
||||
task by ID instead of starting a new one.
|
||||
The prompt becomes an optional follow\-up message.
|
||||
.SH JSON OUTPUT FORMAT
|
||||
When using \f[B]\-\-json\f[R], each message is output as a JSON object
|
||||
with these fields:
|
||||
@@ -234,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
|
||||
@@ -274,6 +285,21 @@ cline history
|
||||
\f[I]# Show more tasks with pagination\f[R]
|
||||
cline history \-n 20 \-p 2
|
||||
.EE
|
||||
.SS Resuming Tasks
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Resume a task by ID (get IDs from cline history)\f[R]
|
||||
cline \-T abc123def
|
||||
|
||||
\f[I]# Resume a task with a follow\-up message\f[R]
|
||||
cline \-T abc123def \(dqNow add unit tests for the changes\(dq
|
||||
|
||||
\f[I]# Resume in plan mode to review before continuing\f[R]
|
||||
cline \-T abc123def \-p \(dqWhat\(aqs left to do?\(dq
|
||||
|
||||
\f[I]# Resume with yolo mode for automated continuation\f[R]
|
||||
cline \-T abc123def \-y \(dqContinue with the implementation\(dq
|
||||
.EE
|
||||
.SS Authentication
|
||||
.IP
|
||||
.EX
|
||||
@@ -286,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
|
||||
@@ -348,20 +377,19 @@ export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(
|
||||
\f[I]# Allow file operations with redirects\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
|
||||
.EE
|
||||
.SH FILES
|
||||
\f[B]\(ti/.cline/data/\f[R] : Default configuration directory
|
||||
containing:
|
||||
.SH CONFIGURATION FILES
|
||||
.IP
|
||||
.EX
|
||||
\(ti/.cline/
|
||||
├── data/ # Default configuration directory
|
||||
│ ├── globalState.json # Global settings and state
|
||||
│ ├── secrets.json # API keys and secrets (stored securely)
|
||||
│ ├── workspace/ # Workspace\-specific state
|
||||
│ └── tasks/ # Task history and conversation data
|
||||
└── log/ # Log files for debugging
|
||||
.EE
|
||||
.PP
|
||||
\f[B]globalState.json\f[R] : Global settings and state
|
||||
.PP
|
||||
\f[B]secrets.json\f[R] : API keys and secrets (stored securely)
|
||||
.PP
|
||||
\f[B]workspace/\f[R] : Workspace\-specific state
|
||||
.PP
|
||||
\f[B]tasks/\f[R] : Task history and conversation data
|
||||
.PP
|
||||
\f[B]\(ti/.cline/log/\f[R] : Log files for debugging.
|
||||
View with \f[CR]cline dev log\f[R].
|
||||
View logs with \f[CR]cline dev log\f[R].
|
||||
.SH BUGS
|
||||
Report bugs at: \c
|
||||
.UR https://github.com/cline/cline/issues
|
||||
|
||||
@@ -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
|
||||
@@ -70,6 +72,8 @@ Run a new task with a prompt.
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text
|
||||
|
||||
**-T**, **\--taskId** *id* : Resume an existing task by ID. The prompt argument becomes an optional follow-up message.
|
||||
|
||||
## history (alias: h)
|
||||
|
||||
List task history with pagination.
|
||||
@@ -142,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
|
||||
@@ -154,6 +160,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
|
||||
|
||||
**-T**, **\--taskId** *id* : Resume an existing task by ID instead of starting a new one. The prompt becomes an optional follow-up message.
|
||||
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
@@ -251,6 +259,22 @@ cline history
|
||||
cline history -n 20 -p 2
|
||||
```
|
||||
|
||||
## Resuming Tasks
|
||||
|
||||
```bash
|
||||
# Resume a task by ID (get IDs from cline history)
|
||||
cline -T abc123def
|
||||
|
||||
# Resume a task with a follow-up message
|
||||
cline -T abc123def "Now add unit tests for the changes"
|
||||
|
||||
# Resume in plan mode to review before continuing
|
||||
cline -T abc123def -p "What's left to do?"
|
||||
|
||||
# Resume with yolo mode for automated continuation
|
||||
cline -T abc123def -y "Continue with the implementation"
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
|
||||
Generated
-2950
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -1,25 +1,34 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.0.1",
|
||||
"version": "2.4.2",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"man": "./man/cline.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "npm run build:production",
|
||||
"package:brew": "npx tsx ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "npx tsx esbuild.mts",
|
||||
"build:production": "npx tsx esbuild.mts --production",
|
||||
"build": "npm run typecheck && npx tsx esbuild.mts",
|
||||
"build:production": "npm run typecheck && npx tsx esbuild.mts --production",
|
||||
"watch": "npx tsx esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "npx tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
|
||||
@@ -172,6 +172,16 @@ class ACPEnvServiceClient implements EnvServiceClientInterface {
|
||||
Logger.debug("[ACPEnvServiceClient] shutdown called (stub)")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async openExternal(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
const url = request.value || ""
|
||||
if (url) {
|
||||
Logger.debug(`[ACPEnvServiceClient] openExternal: ${url}`)
|
||||
const { openUrlInBrowser } = await import("../utils/browser")
|
||||
await openUrlInBrowser(url)
|
||||
}
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-58
@@ -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,12 +52,12 @@ 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 { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
|
||||
import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js"
|
||||
import { AcpTerminalManager } from "../acp/AcpTerminalManager.js"
|
||||
import { isAuthConfigured } from "../index.js"
|
||||
import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { CliContextResult, initializeCliContext } from "../vscode-context.js"
|
||||
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
|
||||
@@ -72,6 +73,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 },
|
||||
}
|
||||
@@ -173,8 +175,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,
|
||||
@@ -246,8 +248,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 +265,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()
|
||||
}
|
||||
@@ -337,9 +339,7 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
// Use provider-specific model ID key (e.g., cline uses actModeOpenRouterModelId)
|
||||
const modelKey = currentProvider ? getProviderModelIdKey(currentProvider, mode) : null
|
||||
const currentModelId = modelKey
|
||||
? (stateManager.getGlobalSettingsKey(modelKey as string) as string | undefined)
|
||||
: undefined
|
||||
const currentModelId = modelKey ? stateManager.getGlobalSettingsKey(modelKey) : undefined
|
||||
|
||||
// Build the current model ID in provider/model format
|
||||
const currentFullModelId =
|
||||
@@ -975,7 +975,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)
|
||||
@@ -1006,13 +1006,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()
|
||||
@@ -1145,48 +1146,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 authData = await secretStorage.get("cline:clineAccountId")
|
||||
return !!authData
|
||||
}
|
||||
|
||||
// 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.
|
||||
*
|
||||
@@ -1200,9 +1159,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()
|
||||
|
||||
|
||||
@@ -312,6 +312,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
|
||||
|
||||
@@ -34,7 +34,7 @@ type AsciiMotionCliProps = {
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
onReady?: (api: PlaybackAPI) => void;
|
||||
onScroll?: () => void; // Called when user scrolls (scroll wheel)
|
||||
onInteraction?: () => void; // Called when user scrolls, clicks, or drags
|
||||
};
|
||||
|
||||
const FRAMES: FrameData[] = [
|
||||
@@ -333364,7 +333364,7 @@ const FRAME_BOTTOM_RIGHT = 128;
|
||||
|
||||
export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
hasDarkBackground = true,
|
||||
onScroll,
|
||||
onInteraction,
|
||||
}) => {
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
const [targetFrame, setTargetFrame] = useState(0);
|
||||
@@ -333390,13 +333390,13 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
// Stop animation on terminal resize to prevent visual glitches
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
onScroll?.();
|
||||
onInteraction?.();
|
||||
};
|
||||
process.stdout.on("resize", handleResize);
|
||||
return () => {
|
||||
process.stdout.off("resize", handleResize);
|
||||
};
|
||||
}, [onScroll]);
|
||||
}, [onInteraction]);
|
||||
|
||||
// Mouse tracking - gracefully handle environments without tty support
|
||||
useEffect(() => {
|
||||
@@ -333417,13 +333417,19 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
const handleData = (data: Buffer) => {
|
||||
const str = data.toString();
|
||||
|
||||
// Parse mouse events: \x1b[<button;x;yM
|
||||
// Parse mouse events: \x1b[<button;x;yM (M=press, m=release)
|
||||
const mouseMatch = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
||||
if (mouseMatch) {
|
||||
const button = parseInt(mouseMatch[1], 10);
|
||||
// Button 64 = scroll up, 65 = scroll down
|
||||
if (button === 64 || button === 65) {
|
||||
onScroll?.();
|
||||
const isPress = mouseMatch[4] === "M";
|
||||
// Button 64/65 = scroll up/down
|
||||
// Button 0-2 = left/middle/right click (on press)
|
||||
// Button 32-34 = drag with left/middle/right button held
|
||||
const isScroll = button === 64 || button === 65;
|
||||
const isClick = isPress && button >= 0 && button <= 2;
|
||||
const isDrag = button >= 32 && button <= 34;
|
||||
if (isScroll || isClick || isDrag) {
|
||||
onInteraction?.();
|
||||
}
|
||||
// Throttle cursor updates to ~20fps to reduce re-renders
|
||||
const now = Date.now();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles different types of user interactions (text input, confirmations, choices)
|
||||
*/
|
||||
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
@@ -11,7 +11,6 @@ import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { getCliMessagePrefixIcon } from "./MessageRow"
|
||||
|
||||
interface AskPromptProps {
|
||||
onRespond?: (response: string) => void
|
||||
@@ -372,3 +371,73 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji icon for message type
|
||||
*/
|
||||
function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return "❓"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "api_req_failed":
|
||||
return "❌"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "▶️"
|
||||
case "browser_action_launch":
|
||||
return "🌐"
|
||||
case "use_mcp_server":
|
||||
return "🔌"
|
||||
case "plan_mode_respond":
|
||||
return "📋"
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+138
-99
@@ -6,21 +6,24 @@
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
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 type { ApiProvider } from "@/shared/api"
|
||||
import { openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { getAllFeaturedModels } from "../constants/featured-models"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
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,
|
||||
@@ -29,8 +32,9 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CLI_EXCLUDED_PROVIDERS, getProviderLabel, getProviderOrder } from "./ProviderPicker"
|
||||
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getProviderLabel } from "./ProviderPicker"
|
||||
|
||||
type AuthStep =
|
||||
| "menu"
|
||||
@@ -42,13 +46,13 @@ type AuthStep =
|
||||
| "success"
|
||||
| "error"
|
||||
| "cline_auth"
|
||||
| "oca_employee_check"
|
||||
| "oca_auth"
|
||||
| "cline_model"
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
|
||||
// Featured models loaded from shared constants
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
| "bedrock_custom"
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
@@ -74,7 +78,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) {
|
||||
@@ -140,7 +144,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>
|
||||
)
|
||||
@@ -148,6 +156,9 @@ const TextInput: React.FC<{
|
||||
|
||||
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
const providers = useValidProviders()
|
||||
|
||||
const [step, setStep] = useState<AuthStep>("menu")
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(
|
||||
StateManager.get().getApiConfiguration().actModeApiProvider ||
|
||||
@@ -158,7 +169,6 @@ 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)
|
||||
@@ -166,11 +176,32 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
|
||||
// Use providers.json order, filtered to exclude CLI-incompatible providers
|
||||
const sortedProviders = useMemo(() => {
|
||||
return getProviderOrder().filter((p) => !CLI_EXCLUDED_PROVIDERS.has(p))
|
||||
// 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")
|
||||
const actModelId = stateManager.getGlobalSettingsKey("actModeOcaModelId") || ""
|
||||
setModelId(actModelId)
|
||||
setStep("success")
|
||||
}, [controller])
|
||||
|
||||
const handleOcaAuthError = useCallback((error: Error) => {
|
||||
setErrorMessage(error.message)
|
||||
setStep("error")
|
||||
}, [])
|
||||
|
||||
const { startAuth: initiateOcaAuth } = useOcaAuth({
|
||||
controller,
|
||||
enabled: step === "oca_auth",
|
||||
onSuccess: handleOcaAuthSuccess,
|
||||
onError: handleOcaAuthError,
|
||||
})
|
||||
|
||||
// Main menu items - conditionally include import options
|
||||
const mainMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Sign in with Cline", value: "cline_auth" }]
|
||||
@@ -196,15 +227,13 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const providerItems: SelectItem[] = useMemo(() => {
|
||||
const search = providerSearch.toLowerCase()
|
||||
const filtered = providerSearch
|
||||
? sortedProviders.filter(
|
||||
(p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search),
|
||||
)
|
||||
: sortedProviders
|
||||
? providers.filter((p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search))
|
||||
: providers
|
||||
return filtered.map((p: string) => ({
|
||||
label: getProviderLabel(p),
|
||||
value: p,
|
||||
}))
|
||||
}, [sortedProviders, providerSearch])
|
||||
}, [providers, providerSearch])
|
||||
|
||||
// Use shared scrollable list hook for provider windowing
|
||||
const TOTAL_PROVIDER_ROWS = 8
|
||||
@@ -225,6 +254,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])
|
||||
@@ -250,23 +280,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
// Auth succeeded - save configuration and transition to success
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
// Use provider-specific model ID key (cline uses OpenRouterModelId)
|
||||
const modelIdKey = getProviderModelIdKey("cline" as ApiProvider, mode as "act" | "plan")
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "cline",
|
||||
[providerKey]: "cline",
|
||||
}
|
||||
if (modelIdKey) {
|
||||
config[modelIdKey] = openRouterDefaultModelId
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.flushPendingState()
|
||||
|
||||
if (authState.user?.email) {
|
||||
// Auth succeeded - save configuration and transition to model selection
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setSelectedProvider("cline")
|
||||
setModelId(openRouterDefaultModelId)
|
||||
setStep("cline_model")
|
||||
@@ -295,23 +311,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
await openAiCodexOAuthManager.waitForCallback()
|
||||
|
||||
// Success - save configuration
|
||||
await applyProviderConfig({ providerId: "openai-codex", controller })
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
// Use provider-specific model ID key (openai-codex uses generic apiModelId)
|
||||
const modelIdKey = getProviderModelIdKey("openai-codex" as ApiProvider, mode as "act" | "plan")
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "openai-codex",
|
||||
planModeApiProvider: "openai-codex",
|
||||
[providerKey]: "openai-codex",
|
||||
}
|
||||
if (modelIdKey) {
|
||||
config[modelIdKey] = openAiCodexDefaultModelId
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("openai-codex")
|
||||
setModelId(openAiCodexDefaultModelId)
|
||||
setStep("success")
|
||||
@@ -326,7 +329,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))
|
||||
@@ -334,6 +336,11 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
const startOcaAuth = useCallback(() => {
|
||||
setStep("oca_auth")
|
||||
initiateOcaAuth()
|
||||
}, [initiateOcaAuth])
|
||||
|
||||
const handleMainMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "exit") {
|
||||
@@ -360,8 +367,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "cline") {
|
||||
startClineAuth()
|
||||
if (value === "oca") {
|
||||
// Show employee check screen before starting auth
|
||||
setStep("oca_employee_check")
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
@@ -371,7 +379,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setStep("apikey")
|
||||
}
|
||||
},
|
||||
[startClineAuth, startOpenAiCodexAuth],
|
||||
[startOcaAuth, startOpenAiCodexAuth],
|
||||
)
|
||||
|
||||
const handleApiKeySubmit = useCallback(
|
||||
@@ -388,55 +396,53 @@ 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 {
|
||||
const stateManager = StateManager.get()
|
||||
// Use provider-specific model ID keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
const actModelKey = getProviderModelIdKey(selectedProvider as ApiProvider, "act")
|
||||
const planModelKey = getProviderModelIdKey(selectedProvider as ApiProvider, "plan")
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedProvider,
|
||||
planModeApiProvider: selectedProvider,
|
||||
apiProvider: selectedProvider,
|
||||
}
|
||||
if (actModelKey) config[actModelKey] = model
|
||||
if (planModelKey) config[planModelKey] = model
|
||||
|
||||
// For cline/openrouter, also set model info (required for getModel() to return correct model)
|
||||
if (selectedProvider === "cline" || selectedProvider === "openrouter") {
|
||||
const openRouterModels = await controller?.readOpenRouterModels()
|
||||
const modelInfo = openRouterModels?.[model]
|
||||
if (modelInfo) {
|
||||
stateManager.setGlobalState("actModeOpenRouterModelInfo", modelInfo)
|
||||
stateManager.setGlobalState("planModeOpenRouterModelInfo", modelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Add API key or Bedrock-specific config
|
||||
if (selectedProvider === "bedrock" && bedrockConfig) {
|
||||
const bedrockFields: Record<string, unknown> = {
|
||||
awsAuthentication: bedrockConfig.awsAuthentication,
|
||||
awsRegion: bedrockConfig.awsRegion,
|
||||
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
|
||||
}
|
||||
if (bedrockConfig.awsProfile !== undefined) bedrockFields.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) bedrockFields.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
if (bedrockConfig.awsSecretKey) bedrockFields.awsSecretKey = bedrockConfig.awsSecretKey
|
||||
if (bedrockConfig.awsSessionToken) bedrockFields.awsSessionToken = bedrockConfig.awsSessionToken
|
||||
Object.assign(config, bedrockFields)
|
||||
} else if (apiKey) {
|
||||
const keyField = ProviderToApiKeyMap[selectedProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
config[fields[0]] = apiKey
|
||||
}
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: model,
|
||||
controller,
|
||||
})
|
||||
} else {
|
||||
await applyProviderConfig({
|
||||
providerId: selectedProvider,
|
||||
apiKey,
|
||||
modelId: model,
|
||||
baseUrl: base,
|
||||
controller,
|
||||
})
|
||||
}
|
||||
|
||||
if (base) {
|
||||
config.openAiBaseUrl = base
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
@@ -451,6 +457,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)
|
||||
}
|
||||
@@ -558,6 +570,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")
|
||||
}
|
||||
@@ -566,6 +581,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setBaseUrl("")
|
||||
setStep("modelid")
|
||||
break
|
||||
case "oca_employee_check":
|
||||
setStep("provider")
|
||||
break
|
||||
case "oca_auth":
|
||||
setStep("oca_employee_check")
|
||||
break
|
||||
case "cline_auth":
|
||||
setStep("menu")
|
||||
break
|
||||
@@ -668,7 +689,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>
|
||||
@@ -704,6 +725,10 @@ 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 (
|
||||
<Box flexDirection="column">
|
||||
@@ -759,6 +784,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
|
||||
@@ -788,11 +825,13 @@ 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",
|
||||
"baseurl",
|
||||
"cline_auth",
|
||||
"oca_auth",
|
||||
"cline_model",
|
||||
"openai_codex_auth",
|
||||
"bedrock",
|
||||
@@ -885,7 +924,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
{index === menuIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.value === "cline_auth" && <Text color="yellow"> (try Kimi K2.5 free!)</Text>}
|
||||
{item.value === "cline_auth" && <Text color="yellow"> (try Opus 4.6!)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -10,12 +10,14 @@ import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type { ClineAskUseMcpServer, ClineMessage } 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"
|
||||
import { getToolDescription, isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { DiffView } from "./DiffView"
|
||||
import { SubagentMessage } from "./SubagentMessage"
|
||||
|
||||
/**
|
||||
* Add "(Tab)" hint after "Act mode" mentions.
|
||||
@@ -23,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)
|
||||
@@ -40,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>,
|
||||
@@ -58,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
|
||||
@@ -67,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]
|
||||
@@ -76,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}
|
||||
@@ -99,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,10 +130,20 @@ interface ChatMessageProps {
|
||||
* For this to work properly, parent containers must have width="100%"
|
||||
* so flexGrow={1} on the content box has a reference width to fill.
|
||||
*/
|
||||
const DotRow: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color }) => (
|
||||
const DotRow: React.FC<{ children: React.ReactNode; color?: string; flashing?: boolean }> = ({
|
||||
children,
|
||||
color,
|
||||
flashing = false,
|
||||
}) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text color={color}>⏺</Text>
|
||||
{flashing ? (
|
||||
<Text color={color}>
|
||||
<Spinner type="toggle8" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>⏺</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
@@ -213,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
|
||||
@@ -223,8 +237,8 @@ function formatToolResult(result: string, maxLines: number = 5): string[] {
|
||||
return displayLines
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
const { type, ask, say, text } = message
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStreaming }) => {
|
||||
const { type, ask, say, text, partial } = message
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
|
||||
@@ -280,11 +294,11 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (isFileEditTool(toolInfo.toolName) && filePath && toolInfo.args.content) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<DiffView content={toolInfo.args.content} filePath={filePath as string | undefined} />
|
||||
<DiffView content={toolInfo.args.content as string} filePath={filePath as string | undefined} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
@@ -299,7 +313,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
{contentLines.length > 0 && (
|
||||
@@ -318,7 +332,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (isToolSay) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>{truncate(text, 100)}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
@@ -340,7 +354,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{label}</Text>
|
||||
<Text>{truncate(command, 120)}</Text>
|
||||
@@ -379,12 +393,12 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if ((type === "ask" && ask === "use_mcp_server") || say === "use_mcp_server") {
|
||||
const isAsk = type === "ask"
|
||||
const parsed = text
|
||||
? jsonParseSafe<ClineAskUseMcpServer>(text, {
|
||||
type: undefined as ClineAskUseMcpServer["type"] | undefined,
|
||||
? jsonParseSafe<Partial<ClineAskUseMcpServer> & { serverName: string }>(text, {
|
||||
type: undefined,
|
||||
serverName: "unknown server",
|
||||
toolName: undefined as string | undefined,
|
||||
arguments: undefined as string | undefined,
|
||||
uri: undefined as string | undefined,
|
||||
toolName: undefined,
|
||||
arguments: undefined,
|
||||
uri: undefined,
|
||||
})
|
||||
: undefined
|
||||
|
||||
@@ -410,7 +424,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{actionLabel}</Text>
|
||||
<Text>{`: ${serverName}`}</Text>
|
||||
@@ -435,12 +449,16 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>MCP response</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
@@ -581,7 +599,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (say === "browser_action" || say === "browser_action_launch") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline used the browser</Text>
|
||||
{text && (
|
||||
@@ -600,7 +618,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (say === "mcp_server_request_started") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline is using an MCP tool</Text>
|
||||
{text && (
|
||||
@@ -728,7 +746,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (type === "ask" && ask === "condense" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue}>
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to condense your conversation:
|
||||
</Text>
|
||||
@@ -744,7 +762,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (type === "ask" && ask === "summarize_task" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue}>
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to summarize the task:
|
||||
</Text>
|
||||
@@ -760,7 +778,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
if (type === "ask" && ask === "report_bug" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue}>
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to create a Github issue:
|
||||
</Text>
|
||||
@@ -789,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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for ChatView component exit and cleanup behavior
|
||||
*
|
||||
* These tests verify that when the user exits (via Ctrl+C or other means),
|
||||
* These tests verify that when the user exits (via shutdown event or other means),
|
||||
* the input field is properly hidden before the app terminates.
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
// Using 60ms since handleExit has a 50ms setTimeout
|
||||
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Type for our exit mock function
|
||||
@@ -175,12 +174,21 @@ vi.mock("@shared/getApiMetrics", () => ({
|
||||
totalTokensOut: 0,
|
||||
totalCost: 0,
|
||||
})),
|
||||
getLastApiReqTotalTokens: vi.fn(() => 0),
|
||||
}))
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
exec: vi.fn(),
|
||||
execSync: vi.fn(() => "main"),
|
||||
}))
|
||||
|
||||
// Mock telemetry service to prevent HostProvider errors in shutdown handler
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Helper to create a typed mock for onExit
|
||||
const createExitMock = (): ExitMockFn => vi.fn() as ExitMockFn
|
||||
|
||||
@@ -213,33 +221,6 @@ describe("ChatView Exit and Cleanup", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Ctrl+C exit handling", () => {
|
||||
it("should hide input but keep footer, then call onExit", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Verify UI visible before Ctrl+C
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
|
||||
// Simulate Ctrl+C
|
||||
stdin.write("\x03")
|
||||
|
||||
// onExit should not be called immediately
|
||||
expect(mockOnExit).not.toHaveBeenCalled()
|
||||
|
||||
// Wait for state update and callback
|
||||
await delay()
|
||||
|
||||
// Input should be hidden, but footer should remain
|
||||
const frameAfter = lastFrame()
|
||||
expect(frameAfter).not.toContain("Input:")
|
||||
expect(frameAfter).toContain("@ for files")
|
||||
|
||||
// onExit should have been called
|
||||
expect(mockOnExit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Shutdown event handling", () => {
|
||||
it("should subscribe on mount and unsubscribe on unmount", () => {
|
||||
const { unmount } = render(<ChatView onExit={mockOnExit} />)
|
||||
@@ -249,39 +230,59 @@ describe("ChatView Exit and Cleanup", () => {
|
||||
expect(shutdownMockState.listeners.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should hide UI when shutdown event fires", async () => {
|
||||
it("should hide input when shutdown event fires", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Input should be visible initially
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
// Fire shutdown event (simulates Ctrl+C)
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
// Input should be hidden after shutdown
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should preserve footer when shutdown event fires", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Footer should be visible initially
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
// Footer should still be present (only input is hidden)
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle exit when onExit prop is undefined", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView />)
|
||||
it("should handle shutdown event when onExit prop is undefined", async () => {
|
||||
const { lastFrame } = render(<ChatView />)
|
||||
|
||||
stdin.write("\x03")
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
// Should not throw, UI should still hide
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should handle multiple Ctrl+C presses gracefully", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
it("should handle multiple shutdown events gracefully", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
// Fire multiple shutdown events
|
||||
shutdownMockState.fire()
|
||||
shutdownMockState.fire()
|
||||
shutdownMockState.fire()
|
||||
|
||||
await delay()
|
||||
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
// UI should still hide properly
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -294,14 +295,15 @@ describe("ChatView UI State During Exit", () => {
|
||||
|
||||
it("should preserve static content and footer, only hide input during exit", async () => {
|
||||
const onExit = createExitMock()
|
||||
const { lastFrame, stdin } = render(<ChatView onExit={onExit} />)
|
||||
const { lastFrame } = render(<ChatView onExit={onExit} />)
|
||||
|
||||
// Footer contains auto-approve toggle
|
||||
expect(lastFrame()).toContain("Auto-approve")
|
||||
expect(lastFrame()).toContain("What can I do for you?")
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
stdin.write("\x03")
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
const frameAfter = lastFrame()
|
||||
|
||||
+149
-35
@@ -103,15 +103,17 @@
|
||||
|
||||
import type { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { combineHookSequences } from "@shared/combineHookSequences"
|
||||
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { getProviderModelIdKey } from "@shared/storage"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Static, Text, useApp, useInput } from "ink"
|
||||
// biome-ignore lint/style/useImportType: JSX requires React as a value (jsx: "react" in tsconfig)
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
@@ -136,6 +138,7 @@ import {
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
|
||||
import { waitFor } from "../utils/timeout"
|
||||
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { shutdownEvent } from "../vscode-shim"
|
||||
import { ActionButtons, type ButtonActionType, getButtonConfig, getVisibleButtons } from "./ActionButtons"
|
||||
@@ -147,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
|
||||
@@ -208,9 +230,9 @@ function getGitDiffStats(cwd?: string): GitDiffStats | null {
|
||||
const delMatch = output.match(/(\d+) deletion/)
|
||||
|
||||
return {
|
||||
files: filesMatch ? parseInt(filesMatch[1], 10) : 0,
|
||||
additions: addMatch ? parseInt(addMatch[1], 10) : 0,
|
||||
deletions: delMatch ? parseInt(delMatch[1], 10) : 0,
|
||||
files: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
|
||||
additions: addMatch ? Number.parseInt(addMatch[1], 10) : 0,
|
||||
deletions: delMatch ? Number.parseInt(delMatch[1], 10) : 0,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -221,7 +243,7 @@ function getGitDiffStats(cwd?: string): GitDiffStats | null {
|
||||
* Create a progress bar for context window usage
|
||||
* Returns { filled, empty } strings to allow different coloring
|
||||
*/
|
||||
function createContextBar(used: number, total: number, width: number = 8): { filled: string; empty: string } {
|
||||
function createContextBar(used: number, total: number, width = 8): { filled: string; empty: string } {
|
||||
const ratio = Math.min(used / total, 1)
|
||||
// Use ceil so any usage > 0 shows at least one bar
|
||||
const filledCount = used > 0 ? Math.max(1, Math.ceil(ratio * width)) : 0
|
||||
@@ -311,7 +333,7 @@ function parseAskOptions(text: string): string[] {
|
||||
*/
|
||||
function expandPastedTexts(text: string, pastedTexts: Map<number, string>): string {
|
||||
return text.replace(/\[Pasted text #(\d+) \+\d+ lines\]/g, (match, num) => {
|
||||
const content = pastedTexts.get(parseInt(num, 10))
|
||||
const content = pastedTexts.get(Number.parseInt(num, 10))
|
||||
return content ?? match
|
||||
})
|
||||
}
|
||||
@@ -348,9 +370,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
insertText: insertTextAtCursor,
|
||||
} = useTextInput()
|
||||
|
||||
// Ref for text input (used by useHomeEndKeys)
|
||||
// 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
|
||||
const cursorPosRef = useRef(cursorPos)
|
||||
cursorPosRef.current = cursorPos
|
||||
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0) // For file menu
|
||||
@@ -362,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)
|
||||
@@ -384,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)
|
||||
|
||||
@@ -397,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.
|
||||
@@ -423,7 +476,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const [yolo, setYolo] = useState<boolean>(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled") ?? false)
|
||||
const [yolo, _setYolo] = useState<boolean>(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled") ?? false)
|
||||
const [autoApproveAll, setAutoApproveAll] = useState<boolean>(
|
||||
() => StateManager.get().getGlobalSettingsKey("autoApproveAllToggled") ?? false,
|
||||
)
|
||||
@@ -451,11 +504,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Get model ID based on current mode and provider
|
||||
// Different providers use different state keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
// Re-read when activePanel changes (settings panel closes) to pick up changes
|
||||
// Falls back to provider's default model if no model has been explicitly set
|
||||
const modelId = useMemo(() => {
|
||||
if (!provider) return ""
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = getProviderModelIdKey(provider as ApiProvider, mode)
|
||||
return (stateManager.getGlobalSettingsKey(modelKey as string) as string) || ""
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider as ApiProvider) || ""
|
||||
}, [mode, provider, activePanel])
|
||||
|
||||
const toggleMode = useCallback(async () => {
|
||||
@@ -488,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,
|
||||
@@ -604,8 +660,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return true
|
||||
})
|
||||
|
||||
// Combine command messages with their output (like webview does)
|
||||
return combineCommandSequences(filtered)
|
||||
// Combine hook messages with their output, then command messages (like webview does)
|
||||
// CLI always has hooks enabled, so we always apply combineHookSequences
|
||||
const withHooks = combineHookSequences(filtered)
|
||||
return combineCommandSequences(withHooks)
|
||||
}, [messages])
|
||||
|
||||
// Detect task switches by watching first message timestamp change.
|
||||
@@ -752,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)
|
||||
@@ -759,7 +819,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[ctrl, pendingAsk, pastedTexts],
|
||||
[ctrl, pendingAsk, pastedTexts, storageKey],
|
||||
)
|
||||
|
||||
// Handle cancel/interrupt
|
||||
@@ -850,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
|
||||
@@ -877,10 +939,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
onError?.()
|
||||
}
|
||||
},
|
||||
[ctrl, onError, pastedTexts],
|
||||
[ctrl, onError, pastedTexts, storageKey],
|
||||
)
|
||||
|
||||
// Auto-submit initial prompt if provided
|
||||
// When taskId is also provided, this sends the prompt to resume the existing task
|
||||
// When no taskId, this creates a new task with the prompt
|
||||
useEffect(() => {
|
||||
const autoSubmit = async () => {
|
||||
if (!initialPrompt && (!initialImages || initialImages.length === 0)) {
|
||||
@@ -901,8 +965,32 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (initialPrompt) {
|
||||
setTerminalTitle(initialPrompt)
|
||||
}
|
||||
// initialImages are already data URLs from index.ts processing
|
||||
await ctrl.initTask(initialPrompt || "", initialImages && initialImages.length > 0 ? initialImages : undefined)
|
||||
|
||||
if (taskId) {
|
||||
// Resuming an existing task with a prompt - wait for task to load first
|
||||
// The task loading happens in the other useEffect via showTaskWithId
|
||||
// We need to wait for it to complete before sending the resume message
|
||||
const task = await waitFor(() => ctrl.task, 5000)
|
||||
|
||||
if (task) {
|
||||
// Send the prompt as a message to resume the task
|
||||
await task.handleWebviewAskResponse("messageResponse", initialPrompt || "")
|
||||
} else {
|
||||
// Task failed to load, fall back to creating new task
|
||||
Logger.error(`Failed to load task ${taskId} for resume, creating new task instead`)
|
||||
await ctrl.initTask(
|
||||
initialPrompt || "",
|
||||
initialImages && initialImages.length > 0 ? initialImages : undefined,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// New task - use initTask
|
||||
// initialImages are already data URLs from index.ts processing
|
||||
await ctrl.initTask(
|
||||
initialPrompt || "",
|
||||
initialImages && initialImages.length > 0 ? initialImages : undefined,
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
onError?.()
|
||||
}
|
||||
@@ -998,11 +1086,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// 3. Handle Option+arrow via key.meta (backup - Ink sometimes parses these instead of passing raw sequence)
|
||||
if (key.meta) {
|
||||
if (key.leftArrow) {
|
||||
setCursorPos(findWordStart(textInput, cursorPos))
|
||||
setCursorPos(findWordStart(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setCursorPos(findWordEnd(textInput, cursorPos))
|
||||
setCursorPos(findWordEnd(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1070,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
|
||||
}
|
||||
@@ -1188,7 +1284,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (hasPrimary && buttonConfig.primaryAction) {
|
||||
handleButtonAction(buttonConfig.primaryAction, true)
|
||||
return
|
||||
} else if (hasSecondary && !hasPrimary && buttonConfig.secondaryAction) {
|
||||
}
|
||||
if (hasSecondary && !hasPrimary && buttonConfig.secondaryAction) {
|
||||
handleButtonAction(buttonConfig.secondaryAction, false)
|
||||
return
|
||||
}
|
||||
@@ -1209,7 +1306,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
// Number selection for options (only when no text typed yet)
|
||||
if (askType === "options") {
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= askOptions.length) {
|
||||
const selectedOption = askOptions[num - 1]
|
||||
sendAskResponse("messageResponse", selectedOption)
|
||||
@@ -1251,10 +1348,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
pasteUpdateTimeoutRef.current = setTimeout(() => {
|
||||
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
|
||||
setTextInput((prev) => {
|
||||
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
|
||||
return prev.replace(pattern, newPlaceholder)
|
||||
})
|
||||
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
|
||||
const newText = textInputRef.current.replace(pattern, newPlaceholder)
|
||||
textInputRef.current = newText // Update ref immediately so setCursorPos bounds check works
|
||||
setTextInput(newText)
|
||||
// Update cursor to be right after the placeholder
|
||||
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
|
||||
Logger.info(`Paste #${pasteNum} complete: ${activePasteLinesRef.current} lines`)
|
||||
@@ -1267,7 +1364,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
pasteCounterRef.current += 1
|
||||
const pasteNum = pasteCounterRef.current
|
||||
activePasteNumRef.current = pasteNum
|
||||
activePasteStartPosRef.current = cursorPos // Track where placeholder starts
|
||||
const currentCursorPos = cursorPosRef.current // Use ref to avoid stale closure
|
||||
activePasteStartPosRef.current = currentCursorPos // Track where placeholder starts
|
||||
// Count line breaks in the pasted content (handle both \n and \r)
|
||||
const extraLines = input.match(/[\r\n]/g)?.length || 0
|
||||
activePasteLinesRef.current = extraLines // Track total lines
|
||||
@@ -1279,8 +1377,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return next
|
||||
})
|
||||
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + placeholder + prev.slice(cursorPos))
|
||||
setCursorPos(cursorPos + placeholder.length)
|
||||
const newText =
|
||||
textInputRef.current.slice(0, currentCursorPos) + placeholder + textInputRef.current.slice(currentCursorPos)
|
||||
textInputRef.current = newText // Update ref immediately so setCursorPos bounds check works
|
||||
setTextInput(newText)
|
||||
setCursorPos(currentCursorPos + placeholder.length)
|
||||
return // Exit early - don't also add the raw input via normal handling below
|
||||
}
|
||||
|
||||
@@ -1309,15 +1410,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return
|
||||
}
|
||||
if (key.rightArrow && !inSlashMenu && !inFileMenu) {
|
||||
setCursorPos((pos) => Math.min(textInput.length, pos + 1))
|
||||
setCursorPos((pos) => Math.min(textInputRef.current.length, pos + 1))
|
||||
return
|
||||
}
|
||||
if (key.upArrow && !inSlashMenu && !inFileMenu) {
|
||||
setCursorPos(moveCursorUp(textInput, cursorPos))
|
||||
setCursorPos(moveCursorUp(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
if (key.downArrow && !inSlashMenu && !inFileMenu) {
|
||||
setCursorPos(moveCursorDown(textInput, cursorPos))
|
||||
setCursorPos(moveCursorDown(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
// Normal input (single char or short paste)
|
||||
@@ -1383,10 +1484,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
{/* Dynamic region - only current streaming message + input */}
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Animated robot and welcome text - only shown before messages start and user hasn't scrolled */}
|
||||
{/* Animated robot and welcome text - only shown before messages start and user hasn't interacted */}
|
||||
{isWelcomeState && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<AsciiMotionCli onScroll={() => setUserScrolled(true)} />
|
||||
<AsciiMotionCli onInteraction={() => setUserScrolled(true)} />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
@@ -1454,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,33 +27,33 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{title && (
|
||||
<>
|
||||
<Text>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{featuredModels.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"}>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.label && (
|
||||
<>
|
||||
{model.labels.map((label) => (
|
||||
<Text key={label}>
|
||||
<Text> </Text>
|
||||
<Text backgroundColor={model.label === "FREE" ? "gray" : COLORS.primaryBlue} color="black">
|
||||
<Text backgroundColor={label === "FREE" ? "gray" : COLORS.primaryBlue} color="black">
|
||||
{" "}
|
||||
{model.label}{" "}
|
||||
{label}{" "}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{model.description}</Text>
|
||||
@@ -81,7 +81,7 @@ 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 {
|
||||
export function getFeaturedModelMaxIndex(showBrowseAll = true): number {
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
|
||||
}
|
||||
|
||||
@@ -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,8 +6,6 @@
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiProvider } from "@/shared/api"
|
||||
import { getProviderModelIdKey } from "@/shared/storage"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
|
||||
@@ -61,25 +60,12 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
return
|
||||
}
|
||||
|
||||
await applyProviderConfig({
|
||||
providerId: selectedKey.provider,
|
||||
apiKey: selectedKey.key,
|
||||
modelId: selectedKey.modelId,
|
||||
})
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedKey.provider,
|
||||
planModeApiProvider: selectedKey.provider,
|
||||
apiProvider: selectedKey.provider,
|
||||
}
|
||||
|
||||
// Set API key
|
||||
config[selectedKey.keyField] = selectedKey.key
|
||||
|
||||
// Set model ID if available (use provider-specific keys)
|
||||
if (selectedKey.modelId) {
|
||||
const actModelKey = getProviderModelIdKey(selectedKey.provider as ApiProvider, "act")
|
||||
const planModelKey = getProviderModelIdKey(selectedKey.provider as ApiProvider, "plan")
|
||||
if (actModelKey) config[actModelKey] = selectedKey.modelId
|
||||
if (planModelKey) config[planModelKey] = selectedKey.modelId
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -20,11 +20,13 @@ interface PanelProps {
|
||||
tabs?: PanelTab[]
|
||||
/** Current tab key - required when tabs are provided */
|
||||
currentTab?: string
|
||||
/** Whether currently in a subpage (shows "Esc to go back" and hides arrow key hint) */
|
||||
isSubpage?: boolean
|
||||
/** Panel content */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children }) => {
|
||||
export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, isSubpage, children }) => {
|
||||
const { columns } = useTerminalSize()
|
||||
const currentTabIndex = currentTab && tabs ? tabs.findIndex((t) => t.key === currentTab) : 0
|
||||
|
||||
@@ -35,7 +37,7 @@ export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text color="gray"> (Esc to close)</Text>
|
||||
<Text color="gray"> (Esc to {isSubpage ? "go back" : "close"})</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tab bar if tabs are provided */}
|
||||
@@ -53,7 +55,7 @@ export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
<Text color="gray"> (←/→)</Text>
|
||||
{!isSubpage && <Text color="gray"> (←/→)</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiConfiguration } from "@/shared/api"
|
||||
import { CLI_EXCLUDED_PROVIDERS, getProviderLabel, getProviderOrder } from "../utils/providers"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
import { getProviderLabel, useValidProviders } from "../utils/providers"
|
||||
import { SearchableList, type SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { CLI_EXCLUDED_PROVIDERS, getProviderLabel, getProviderOrder }
|
||||
export { getProviderLabel }
|
||||
|
||||
/**
|
||||
* Check if a provider is configured (has required credentials/settings)
|
||||
@@ -18,8 +18,8 @@ export { CLI_EXCLUDED_PROVIDERS, getProviderLabel, getProviderOrder }
|
||||
function isProviderConfigured(providerId: string, config: ApiConfiguration): boolean {
|
||||
switch (providerId) {
|
||||
case "cline":
|
||||
// Check if user has Cline account auth data stored
|
||||
return !!(config as Record<string, unknown>)["cline:clineAccountId"]
|
||||
// Check if user has Cline API key or Cline account auth data stored
|
||||
return !!(config.clineApiKey ?? config["cline:clineAccountId"])
|
||||
case "anthropic":
|
||||
return !!config.apiKey
|
||||
case "openrouter":
|
||||
@@ -125,17 +125,16 @@ interface ProviderPickerProps {
|
||||
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true }) => {
|
||||
// Get API configuration to check which providers are configured
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
const sorted = useValidProviders()
|
||||
|
||||
// Use providers.json order, filtered to exclude CLI-incompatible providers
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
const sorted = getProviderOrder().filter((p: string) => !CLI_EXCLUDED_PROVIDERS.has(p))
|
||||
|
||||
return sorted.map((providerId: string) => ({
|
||||
id: providerId,
|
||||
label: getProviderLabel(providerId),
|
||||
suffix: isProviderConfigured(providerId, apiConfig) ? "(Configured)" : undefined,
|
||||
}))
|
||||
}, [apiConfig])
|
||||
}, [apiConfig, sorted])
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -5,25 +5,31 @@
|
||||
|
||||
import type { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@shared/storage"
|
||||
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 { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
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 {
|
||||
@@ -33,7 +39,8 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { LanguagePicker } from "./LanguagePicker"
|
||||
import { getDefaultModelId, 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"
|
||||
@@ -50,13 +57,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" },
|
||||
@@ -67,41 +86,47 @@ 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" as const,
|
||||
stateKey: "useAutoCondense",
|
||||
default: false,
|
||||
label: "Auto-condense",
|
||||
description: "Automatically summarize long conversations",
|
||||
},
|
||||
webTools: {
|
||||
stateKey: "clineWebToolsEnabled" as const,
|
||||
stateKey: "clineWebToolsEnabled",
|
||||
default: true,
|
||||
label: "Web tools",
|
||||
description: "Enable web search and fetch tools",
|
||||
},
|
||||
strictPlanMode: {
|
||||
stateKey: "strictPlanModeEnabled" as const,
|
||||
stateKey: "strictPlanModeEnabled",
|
||||
default: true,
|
||||
label: "Strict plan mode",
|
||||
description: "Require explicit mode switching",
|
||||
},
|
||||
nativeToolCall: {
|
||||
stateKey: "nativeToolCallEnabled" as const,
|
||||
stateKey: "nativeToolCallEnabled",
|
||||
default: true,
|
||||
label: "Native tool call",
|
||||
description: "Use model's native tool calling API",
|
||||
},
|
||||
parallelToolCalling: {
|
||||
stateKey: "enableParallelToolCalling" as const,
|
||||
stateKey: "enableParallelToolCalling",
|
||||
default: false,
|
||||
label: "Parallel tool calling",
|
||||
description: "Allow multiple tools in a single response",
|
||||
},
|
||||
skillsEnabled: {
|
||||
stateKey: "skillsEnabled" as const,
|
||||
doubleCheckCompletion: {
|
||||
stateKey: "doubleCheckCompletionEnabled",
|
||||
default: false,
|
||||
label: "Skills",
|
||||
description: "Enable reusable agent instructions",
|
||||
label: "Double-check completion",
|
||||
description: "Reject first completion attempt and require re-verification",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -141,16 +166,24 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
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> = {}
|
||||
for (const [key, config] of Object.entries(FEATURE_SETTINGS)) {
|
||||
initial[key] = stateManager.getGlobalSettingsKey(config.stateKey) ?? config.default
|
||||
if (isSettingsKey(config.stateKey)) {
|
||||
initial[key] = stateManager.getGlobalSettingsKey(config.stateKey)
|
||||
} else {
|
||||
initial[key] = stateManager.getGlobalStateKey(config.stateKey)
|
||||
}
|
||||
}
|
||||
return initial as Record<FeatureKey, boolean>
|
||||
})
|
||||
@@ -166,6 +199,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>(() => {
|
||||
@@ -201,6 +240,25 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [modelRefreshKey, setModelRefreshKey] = useState(0)
|
||||
const refreshModelIds = useCallback(() => setModelRefreshKey((k) => k + 1), [])
|
||||
|
||||
// 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])
|
||||
|
||||
const {
|
||||
isWaiting: isWaitingForOcaAuth,
|
||||
startAuth: startOcaAuth,
|
||||
cancelAuth: cancelOcaAuth,
|
||||
isAuthenticated: isOcaAuthenticated,
|
||||
} = useOcaAuth({
|
||||
controller,
|
||||
onSuccess: handleOcaAuthSuccess,
|
||||
})
|
||||
|
||||
// Read model IDs from state (re-reads when refreshKey changes)
|
||||
const { actModelId, planModelId } = useMemo(() => {
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
@@ -209,11 +267,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
if (!actProvider && !planProvider) {
|
||||
return { actModelId: "", planModelId: "" }
|
||||
}
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider as ApiProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider as ApiProvider, "plan") : null
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider, "plan") : null
|
||||
return {
|
||||
actModelId: actKey ? (stateManager.getGlobalSettingsKey(actKey as string) as string) || "" : "",
|
||||
planModelId: planKey ? (stateManager.getGlobalSettingsKey(planKey as string) as string) || "" : "",
|
||||
actModelId: actKey ? (stateManager.getGlobalSettingsKey(actKey) as string) || "" : "",
|
||||
planModelId: planKey ? (stateManager.getGlobalSettingsKey(planKey) as string) || "" : "",
|
||||
}
|
||||
}, [modelRefreshKey, stateManager])
|
||||
|
||||
@@ -379,9 +437,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":
|
||||
@@ -405,7 +466,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
...(showThinkingOption
|
||||
...(showActThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
@@ -415,6 +476,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",
|
||||
@@ -422,7 +493,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: planModelId || "not set",
|
||||
},
|
||||
...(showThinkingOption
|
||||
...(showPlanThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "planThinkingEnabled",
|
||||
@@ -432,6 +503,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: "" },
|
||||
]
|
||||
: [
|
||||
@@ -441,7 +522,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
...(showThinkingOption
|
||||
...(showActThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
@@ -451,6 +532,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showActReasoningEffort
|
||||
? [
|
||||
{
|
||||
key: "actReasoningEffort",
|
||||
label: "Reasoning effort",
|
||||
type: "cycle" as const,
|
||||
value: actReasoningEffort,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
{
|
||||
key: "separateModels",
|
||||
@@ -477,7 +568,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
key: parentKey,
|
||||
label: parentLabel,
|
||||
type: "checkbox",
|
||||
value: actions[parentKey as keyof typeof actions],
|
||||
value: actions[parentKey as keyof typeof actions] ?? false,
|
||||
description: parentDesc,
|
||||
})
|
||||
if (actions[parentKey as keyof typeof actions]) {
|
||||
@@ -613,6 +704,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
separateModels,
|
||||
actThinkingEnabled,
|
||||
planThinkingEnabled,
|
||||
actReasoningEffort,
|
||||
planReasoningEffort,
|
||||
autoApproveSettings,
|
||||
features,
|
||||
preferredLanguage,
|
||||
@@ -646,6 +739,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]
|
||||
@@ -669,6 +789,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") {
|
||||
@@ -721,12 +850,21 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
if (actProvider) {
|
||||
const actKey = getProviderModelIdKey(actProvider as ApiProvider, "act")
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider as ApiProvider, "plan") : null
|
||||
const actModel = stateManager.getGlobalSettingsKey(actKey as string)
|
||||
const actKey = getProviderModelIdKey(actProvider, "act")
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider, "plan") : null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -734,23 +872,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
|
||||
}
|
||||
|
||||
@@ -807,12 +941,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
|
||||
@@ -823,11 +1008,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
: actProvider || planProvider
|
||||
if (!providerForSelection) return
|
||||
// Use provider-specific model ID keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider as ApiProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider as ApiProvider, "plan") : null
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider, "plan") : null
|
||||
|
||||
// For cline/openrouter providers, also set model info (like webview does)
|
||||
let modelInfo
|
||||
let modelInfo: ModelInfo | undefined
|
||||
if (providerForSelection === "cline" || providerForSelection === "openrouter") {
|
||||
const openRouterModels = await controller?.readOpenRouterModels()
|
||||
modelInfo = openRouterModels?.[modelId]
|
||||
@@ -873,7 +1058,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
|
||||
@@ -914,7 +1099,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
}, [controller])
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(providerId: string) => {
|
||||
async (providerId: string) => {
|
||||
// Special handling for Cline - uses OAuth (but skip if already logged in)
|
||||
if (providerId === "cline") {
|
||||
setIsPickingProvider(false)
|
||||
@@ -922,7 +1107,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const authInfo = AuthService.getInstance(controller).getInfo()
|
||||
if (authInfo?.user?.email) {
|
||||
// Already logged in - just set the provider
|
||||
applyProviderConfig({ providerId: "cline", controller })
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setProvider("cline")
|
||||
refreshModelIds()
|
||||
} else {
|
||||
@@ -939,6 +1124,22 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for OCA - uses OAuth (but skip if already logged in)
|
||||
if (providerId === "oca") {
|
||||
setIsPickingProvider(false)
|
||||
// Check if already logged in
|
||||
if (isOcaAuthenticated) {
|
||||
// Already logged in - just set the provider
|
||||
await applyProviderConfig({ providerId: "oca", controller })
|
||||
setProvider("oca")
|
||||
refreshModelIds()
|
||||
} else {
|
||||
// Not logged in - show employee check before auth
|
||||
setIsShowingOcaEmployeeCheck(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for Bedrock - needs multi-field configuration
|
||||
if (providerId === "bedrock") {
|
||||
setPendingProvider(providerId)
|
||||
@@ -948,7 +1149,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
}
|
||||
|
||||
// Check if this provider needs an API key
|
||||
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
|
||||
const keyField = ProviderToApiKeyMap[providerId as ApiProvider]
|
||||
if (keyField) {
|
||||
// Provider needs an API key - go to API key entry mode
|
||||
// Pre-fill with existing key if configured
|
||||
@@ -961,13 +1162,13 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setIsEnteringApiKey(true)
|
||||
} else {
|
||||
// Provider doesn't need an API key (rare) - just set it
|
||||
applyProviderConfig({ providerId, controller })
|
||||
await applyProviderConfig({ providerId, controller })
|
||||
setProvider(providerId)
|
||||
refreshModelIds()
|
||||
setIsPickingProvider(false)
|
||||
}
|
||||
},
|
||||
[stateManager, startCodexAuth, handleClineLogin, controller, refreshModelIds],
|
||||
[stateManager, startCodexAuth, handleClineLogin, startOcaAuth, isOcaAuthenticated, controller, refreshModelIds],
|
||||
)
|
||||
|
||||
// Handle API key submission after provider selection
|
||||
@@ -990,47 +1191,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
// Handle Bedrock configuration complete
|
||||
const handleBedrockComplete = useCallback(
|
||||
(bedrockConfig: BedrockConfig) => {
|
||||
const config: Record<string, unknown> = {
|
||||
actModeApiProvider: "bedrock",
|
||||
planModeApiProvider: "bedrock",
|
||||
apiProvider: "bedrock",
|
||||
awsAuthentication: bedrockConfig.awsAuthentication,
|
||||
awsRegion: bedrockConfig.awsRegion,
|
||||
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
|
||||
}
|
||||
|
||||
const defaultModelId = getDefaultModelId("bedrock")
|
||||
if (defaultModelId) {
|
||||
// Use provider-specific model ID keys
|
||||
const actModelKey = getProviderModelIdKey("bedrock" as ApiProvider, "act")
|
||||
const planModelKey = getProviderModelIdKey("bedrock" as ApiProvider, "plan")
|
||||
if (actModelKey) config[actModelKey] = defaultModelId
|
||||
if (planModelKey) config[planModelKey] = defaultModelId
|
||||
}
|
||||
|
||||
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
if (bedrockConfig.awsSecretKey) config.awsSecretKey = bedrockConfig.awsSecretKey
|
||||
if (bedrockConfig.awsSessionToken) config.awsSessionToken = bedrockConfig.awsSessionToken
|
||||
|
||||
stateManager.setApiConfiguration(config as Record<string, string>)
|
||||
|
||||
// Close Bedrock config first, then flush state async
|
||||
// Update UI state first for responsiveness
|
||||
setProvider("bedrock")
|
||||
refreshModelIds()
|
||||
setIsConfiguringBedrock(false)
|
||||
setPendingProvider(null)
|
||||
|
||||
// Flush state and rebuild API handler in background
|
||||
stateManager.flushPendingState().then(() => {
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
})
|
||||
// Apply config and rebuild API handler in background
|
||||
applyBedrockConfig({ bedrockConfig, controller })
|
||||
},
|
||||
[stateManager, controller],
|
||||
[controller, refreshModelIds],
|
||||
)
|
||||
|
||||
// Handle saving edited value
|
||||
@@ -1046,8 +1216,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
if (!actProvider && !planProvider) break
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider as ApiProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider as ApiProvider, "plan") : null
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider, "plan") : null
|
||||
|
||||
if (separateModels) {
|
||||
// Only update the selected mode's model
|
||||
@@ -1204,6 +1374,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// OCA OAuth waiting mode - escape to cancel
|
||||
if (isWaitingForOcaAuth) {
|
||||
if (key.escape) {
|
||||
cancelOcaAuth()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Bedrock custom flow - input handled by BedrockCustomModelFlow component
|
||||
if (isBedrockCustomFlow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (key.escape) {
|
||||
setIsEditing(false)
|
||||
@@ -1248,7 +1431,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
|
||||
)
|
||||
|
||||
// Render content
|
||||
@@ -1424,6 +1607,52 @@ 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">
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Waiting for OCA sign-in...</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Complete sign-in in your browser.</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
@@ -1541,6 +1770,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}>
|
||||
@@ -1559,8 +1803,25 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Determine if we're in a subpage (picker, editor, or waiting state)
|
||||
const isSubpage =
|
||||
isPickingProvider ||
|
||||
isPickingModel ||
|
||||
isPickingFeaturedModel ||
|
||||
isPickingLanguage ||
|
||||
isEnteringApiKey ||
|
||||
isConfiguringBedrock ||
|
||||
isWaitingForCodexAuth ||
|
||||
!!codexAuthError ||
|
||||
isPickingOrganization ||
|
||||
isWaitingForClineAuth ||
|
||||
isShowingOcaEmployeeCheck ||
|
||||
isWaitingForOcaAuth ||
|
||||
isBedrockCustomFlow ||
|
||||
isEditing
|
||||
|
||||
return (
|
||||
<Panel currentTab={currentTab} label="Settings" tabs={TABS}>
|
||||
<Panel currentTab={currentTab} isSubpage={isSubpage} label="Settings" tabs={TABS}>
|
||||
{renderContent()}
|
||||
</Panel>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiProvider } from "@/shared/api"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey, Mode } from "@/shared/storage"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey, Mode, SettingsKey } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
@@ -79,7 +79,10 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = getProviderModelIdKey(provider as ApiProvider, mode)
|
||||
return (stateManager.getGlobalSettingsKey(modelKey as string) as string) || getProviderDefaultModelId(provider)
|
||||
return (
|
||||
(stateManager.getGlobalSettingsKey(modelKey as SettingsKey) as string) ||
|
||||
getProviderDefaultModelId(provider as ApiProvider)
|
||||
)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getAllFeaturedModels } 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,50 +7,62 @@ export interface FeaturedModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
label: string
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
export const FEATURED_MODELS = {
|
||||
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.5",
|
||||
name: "Claude Opus 4.5",
|
||||
description: "State-of-the-art for complex coding",
|
||||
label: "Best",
|
||||
id: "google/gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro Preview",
|
||||
description: "Latest Gemini release with 1m ctx window and strong coding performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "Most intelligent model for agents and coding",
|
||||
labels: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
label: "New",
|
||||
labels: ["HOT"],
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
description: "1M context window for large codebases",
|
||||
label: "Trending",
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
],
|
||||
free: [
|
||||
{
|
||||
id: "moonshotai/kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
description: "State-of-the-art model topping benchmarks",
|
||||
label: "FREE",
|
||||
id: "minimax/minimax-m2.5",
|
||||
name: "MiniMax M2.5",
|
||||
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "z-ai/glm-5",
|
||||
name: "Z-AI GLM5",
|
||||
description: "Z.AI's latest GLM 5 model with strong coding and agent performance",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "kwaipilot/kat-coder-pro",
|
||||
name: "KAT Coder Pro",
|
||||
description: "Advanced agentic coding model",
|
||||
label: "FREE",
|
||||
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "arcee-ai/trinity-large-preview:free",
|
||||
name: "Trinity Large Preview",
|
||||
description: "US built open source coding model",
|
||||
label: "FREE",
|
||||
description: "Arcee AI's advanced large preview model in the Trinity series",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
],
|
||||
}
|
||||
|
||||
export function getAllFeaturedModels(): FeaturedModel[] {
|
||||
|
||||
@@ -142,6 +142,17 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
printInfo("Shutting down...")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async openExternal(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
const url = request.value || ""
|
||||
if (url) {
|
||||
printInfo(`🌐 Opening: ${url}`)
|
||||
// Dynamically import 'open' to open URL in default browser
|
||||
const { default: open } = await import("open")
|
||||
await open(url)
|
||||
}
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Hook for OCA OAuth authentication flow in the CLI.
|
||||
* Handles starting auth, subscribing to status updates, and notifying on success.
|
||||
*/
|
||||
|
||||
import type { OcaAuthState } from "@shared/proto/cline/oca_account"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
|
||||
interface UseOcaAuthOptions {
|
||||
controller: Controller | undefined
|
||||
/** If provided, controls when subscription is active (for external state management like AuthView's step) */
|
||||
enabled?: boolean
|
||||
onSuccess?: () => void | Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
interface UseOcaAuthResult {
|
||||
/** Whether we're waiting for auth to complete (only relevant when not using `enabled` prop) */
|
||||
isWaiting: boolean
|
||||
/** Start the OAuth flow - opens browser */
|
||||
startAuth: () => void
|
||||
/** Cancel waiting for auth */
|
||||
cancelAuth: () => void
|
||||
/** The authenticated user, if any */
|
||||
user: OcaAuthState["user"] | null
|
||||
/** Whether user is currently authenticated */
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function useOcaAuth({ controller, enabled, onSuccess, onError }: UseOcaAuthOptions): UseOcaAuthResult {
|
||||
const [isWaiting, setIsWaiting] = useState(false)
|
||||
const [user, setUser] = useState<OcaAuthState["user"] | null>(null)
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
onSuccessRef.current = onSuccess
|
||||
|
||||
// Determine if subscription should be active
|
||||
// If `enabled` is provided, use it; otherwise use internal `isWaiting` state
|
||||
const isSubscriptionActive = enabled !== undefined ? enabled : isWaiting
|
||||
|
||||
const startAuth = useCallback(() => {
|
||||
if (!controller) {
|
||||
return
|
||||
}
|
||||
setIsWaiting(true)
|
||||
OcaAuthService.initialize(controller)
|
||||
OcaAuthService.getInstance()
|
||||
.createAuthRequest()
|
||||
.catch((error) => {
|
||||
setIsWaiting(false)
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}, [controller, onError])
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
setIsWaiting(false)
|
||||
}, [])
|
||||
|
||||
// Check if already authenticated
|
||||
const isAuthenticated = !!user?.uid
|
||||
|
||||
// Subscribe to auth status updates when active
|
||||
useEffect(() => {
|
||||
if (!isSubscriptionActive || !controller) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const responseHandler = async (authState: OcaAuthState) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (authState.user?.uid) {
|
||||
setUser(authState.user)
|
||||
setIsWaiting(false)
|
||||
await onSuccessRef.current?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure OcaAuthService is initialized before subscribing
|
||||
OcaAuthService.initialize(controller)
|
||||
OcaAuthService.getInstance().subscribeToAuthStatusUpdate({}, responseHandler, `cli-oca-auth-${Date.now()}`)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSubscriptionActive, controller])
|
||||
|
||||
return { isWaiting, startAuth, cancelAuth, user, isAuthenticated }
|
||||
}
|
||||
@@ -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).
|
||||
@@ -76,7 +77,7 @@ export interface UseTextInputReturn {
|
||||
cursorPos: number
|
||||
|
||||
// Text manipulation
|
||||
setText: (text: string) => void
|
||||
setText: (text: string | ((prev: string) => string)) => void
|
||||
insertText: (text: string) => void
|
||||
setCursorPos: (pos: number | ((prev: number) => number)) => void
|
||||
|
||||
@@ -102,9 +103,15 @@ export function useTextInput(): UseTextInputReturn {
|
||||
cursorRef.current = cursorPos
|
||||
|
||||
// Text manipulation
|
||||
const setText = useCallback((newText: string) => {
|
||||
setTextState(newText)
|
||||
setCursorPosState(newText.length)
|
||||
const setText = useCallback((newText: string | ((prev: string) => string)) => {
|
||||
setTextState((prev) => {
|
||||
const resolved = typeof newText === "function" ? newText(prev) : newText
|
||||
// Only update cursor to end if setting a direct value (not functional update)
|
||||
if (typeof newText !== "function") {
|
||||
setCursorPosState(resolved.length)
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
}, [])
|
||||
|
||||
const insertText = useCallback((insertedText: string) => {
|
||||
@@ -146,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), [])
|
||||
@@ -184,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
|
||||
@@ -191,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", () => {
|
||||
|
||||
+408
-188
@@ -8,20 +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 { 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"
|
||||
@@ -31,6 +31,7 @@ import { CliCommentReviewController } from "./controllers/CliCommentReviewContro
|
||||
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { restoreConsole } 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"
|
||||
@@ -41,6 +42,253 @@ import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
|
||||
/**
|
||||
* Common options shared between runTask and resumeTask
|
||||
*/
|
||||
interface TaskOptions {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
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.
|
||||
*/
|
||||
function applyTaskOptions(options: TaskOptions): void {
|
||||
// Apply mode flag
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
telemetryService.captureHostEvent("mode_flag", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
telemetryService.captureHostEvent("mode_flag", "act")
|
||||
}
|
||||
|
||||
// Apply model override if specified
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
const modelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (modelKey) {
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
}
|
||||
telemetryService.captureHostEvent("model_flag", options.model)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
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().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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mode selection result using the extracted, testable selectOutputMode function.
|
||||
* This wrapper provides the current process TTY state.
|
||||
*/
|
||||
function getModeSelection(options: TaskOptions) {
|
||||
return selectOutputMode({
|
||||
stdoutIsTTY: process.stdout.isTTY === true,
|
||||
stdinIsTTY: process.stdin.isTTY === true,
|
||||
stdinWasPiped: options.stdinWasPiped ?? false,
|
||||
json: options.json,
|
||||
yolo: options.yolo,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if plain text mode should be used based on options and environment.
|
||||
*/
|
||||
function shouldUsePlainTextMode(options: TaskOptions): boolean {
|
||||
return getModeSelection(options).usePlainTextMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason for using plain text mode (for telemetry).
|
||||
*/
|
||||
function getPlainTextModeReason(options: TaskOptions): string {
|
||||
return getModeSelection(options).reason
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task in plain text mode (no Ink UI).
|
||||
* Handles auth check, task execution, cleanup, and exit.
|
||||
*/
|
||||
async function runTaskInPlainTextMode(
|
||||
ctx: CliContext,
|
||||
options: TaskOptions,
|
||||
taskConfig: {
|
||||
prompt?: string
|
||||
taskId?: string
|
||||
imageDataUrls?: string[]
|
||||
},
|
||||
): Promise<never> {
|
||||
// Set flag so shutdown handler knows not to clear Ink UI lines
|
||||
isPlainTextMode = true
|
||||
|
||||
// Check if auth is configured before attempting to run the task
|
||||
// In plain text mode we can't show the interactive auth flow
|
||||
const hasAuth = await isAuthConfigured()
|
||||
if (!hasAuth) {
|
||||
printWarning("Not authenticated. Please run 'cline auth' first to configure your API credentials.")
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const reason = getPlainTextModeReason(options)
|
||||
telemetryService.captureHostEvent("plain_text_mode", reason)
|
||||
|
||||
// Plain text mode: no Ink rendering, just clean text output
|
||||
const success = await runPlainTextTask({
|
||||
controller: ctx.controller,
|
||||
prompt: taskConfig.prompt,
|
||||
taskId: taskConfig.taskId,
|
||||
imageDataUrls: taskConfig.imageDataUrls,
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
timeoutSeconds: options.timeout ? Number.parseInt(options.timeout, 10) : undefined,
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
await disposeCliContext(ctx)
|
||||
|
||||
// Ensure stdout is fully drained before exiting - critical for piping
|
||||
await drainStdout()
|
||||
exit(success ? 0 : 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the standard cleanup function for Ink apps.
|
||||
*/
|
||||
function createInkCleanup(ctx: CliContext, onTaskError?: () => boolean): () => Promise<void> {
|
||||
return async () => {
|
||||
await disposeCliContext(ctx)
|
||||
if (onTaskError?.()) {
|
||||
printWarning("Task ended with errors.")
|
||||
exit(1)
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Track active context for graceful shutdown
|
||||
let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
@@ -92,10 +340,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
|
||||
}
|
||||
@@ -143,13 +398,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()
|
||||
@@ -158,11 +422,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),
|
||||
@@ -171,28 +433,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
|
||||
@@ -228,24 +482,7 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
|
||||
/**
|
||||
* Run a task with the given prompt - uses welcome view for consistent behavior
|
||||
*/
|
||||
async function runTask(
|
||||
prompt: string,
|
||||
options: {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
timeout?: string
|
||||
images?: string[]
|
||||
json?: boolean
|
||||
stdinWasPiped?: boolean
|
||||
},
|
||||
existingContext?: CliContext,
|
||||
) {
|
||||
async function runTask(prompt: string, options: TaskOptions & { images?: string[] }, existingContext?: CliContext) {
|
||||
const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true }))
|
||||
|
||||
// Parse images from the prompt text (e.g., @/path/to/image.png)
|
||||
@@ -262,101 +499,23 @@ async function runTask(
|
||||
// Task without prompt starts in interactive mode
|
||||
telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive")
|
||||
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
telemetryService.captureHostEvent("mode_flag", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
telemetryService.captureHostEvent("mode_flag", "act")
|
||||
}
|
||||
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
|
||||
// Get the current provider for the selected mode
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
|
||||
// Update model ID using provider-specific key (e.g., cline uses actModeOpenRouterModelId)
|
||||
const modelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (modelKey) {
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
}
|
||||
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)
|
||||
if (options.thinking) {
|
||||
telemetryService.captureHostEvent("thinking_flag", "true")
|
||||
}
|
||||
|
||||
// Set yolo mode based on --yolo flag
|
||||
if (options.yolo) {
|
||||
StateManager.get().setGlobalState("yoloModeToggled", true)
|
||||
telemetryService.captureHostEvent("yolo_flag", "true")
|
||||
}
|
||||
|
||||
// Apply shared task options (mode, model, thinking, yolo)
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
|
||||
const isTTY = process.stdout.isTTY === true
|
||||
|
||||
// Use plain text mode when output is redirected, stdin was piped, JSON mode is enabled, or --yolo flag is used
|
||||
// Ink requires raw mode on stdin which isn't available when stdin is piped
|
||||
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
|
||||
// may not be reliable after stdin has been consumed by readStdinIfPiped()
|
||||
if (!isTTY || options.stdinWasPiped || options.json || options.yolo) {
|
||||
// Set flag so shutdown handler knows not to clear Ink UI lines
|
||||
isPlainTextMode = true
|
||||
|
||||
// Check if auth is configured before attempting to run the task
|
||||
// In plain text mode we can't show the interactive auth flow
|
||||
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()
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const reason = options.yolo
|
||||
? "yolo_flag"
|
||||
: options.json
|
||||
? "json"
|
||||
: options.stdinWasPiped
|
||||
? "piped_stdin"
|
||||
: "redirected_output"
|
||||
telemetryService.captureHostEvent("plain_text_mode", reason)
|
||||
// Plain text mode: no Ink rendering, just clean text output
|
||||
const success = await runPlainTextTask({
|
||||
controller: ctx.controller,
|
||||
if (shouldUsePlainTextMode(options)) {
|
||||
return runTaskInPlainTextMode(ctx, options, {
|
||||
prompt: taskPrompt,
|
||||
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
timeoutSeconds: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
|
||||
// Ensure stdout is fully drained before exiting - critical for piping
|
||||
await drainStdout()
|
||||
exit(success ? 0 : 1)
|
||||
}
|
||||
|
||||
let taskError = false
|
||||
|
||||
// Render the welcome view with optional initial prompt/images
|
||||
// Interactive mode: Render the welcome view with optional initial prompt/images
|
||||
// If prompt provided (cline task "prompt"), ChatView will auto-submit
|
||||
// If no prompt (cline interactive), user will type it in
|
||||
let taskError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "welcome",
|
||||
@@ -369,20 +528,10 @@ async function runTask(
|
||||
taskError = true
|
||||
},
|
||||
onWelcomeExit: () => {
|
||||
// User pressed Esc
|
||||
exit(0)
|
||||
// User pressed Esc; Ink exits and cleanup handles process exit.
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
if (taskError) {
|
||||
printWarning("Task ended with errors.")
|
||||
exit(1)
|
||||
}
|
||||
exit(0)
|
||||
},
|
||||
createInkCleanup(ctx, () => taskError),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -395,8 +544,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)
|
||||
|
||||
@@ -404,9 +553,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)
|
||||
}
|
||||
|
||||
@@ -420,9 +567,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)
|
||||
},
|
||||
)
|
||||
@@ -438,9 +583,6 @@ async function showConfig(options: { config?: string }) {
|
||||
// Dynamically import the wrapper to avoid circular dependencies
|
||||
const { ConfigViewWrapper } = await import("./components/ConfigViewWrapper")
|
||||
|
||||
// Check feature flags
|
||||
const skillsEnabled = stateManager.getGlobalSettingsKey("skillsEnabled") ?? false
|
||||
|
||||
telemetryService.captureHostEvent("config_command", "executed")
|
||||
|
||||
await runInkApp(
|
||||
@@ -450,13 +592,11 @@ async function showConfig(options: { config?: string }) {
|
||||
globalState: stateManager.getAllGlobalStateEntries(),
|
||||
workspaceState: stateManager.getAllWorkspaceStateEntries(),
|
||||
hooksEnabled: true,
|
||||
skillsEnabled,
|
||||
skillsEnabled: true,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
await disposeCliContext(ctx)
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
@@ -532,17 +672,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)
|
||||
}
|
||||
|
||||
@@ -556,7 +694,6 @@ async function runAuth(options: {
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
telemetryService.captureHostEvent("auth", "completed")
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
telemetryService.captureHostEvent("auth", "error")
|
||||
@@ -564,16 +701,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
|
||||
@@ -592,14 +723,23 @@ 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")
|
||||
.action((prompt, options) => runTask(prompt, options))
|
||||
.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) {
|
||||
return resumeTask(options.taskId, { ...options, initialPrompt: prompt })
|
||||
}
|
||||
return runTask(prompt, options)
|
||||
})
|
||||
|
||||
program
|
||||
.command("history")
|
||||
@@ -619,9 +759,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")
|
||||
@@ -657,7 +797,7 @@ devCommand
|
||||
* If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials
|
||||
* and sets the flag accordingly.
|
||||
*/
|
||||
async function isAuthConfigured(): Promise<boolean> {
|
||||
export async function isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check welcomeViewCompleted first - this is the single source of truth
|
||||
@@ -686,7 +826,7 @@ async function checkAnyProviderConfigured(): Promise<boolean> {
|
||||
const config = stateManager.getApiConfiguration() as Record<string, unknown>
|
||||
|
||||
// Check Cline account (stored as "cline:clineAccountId" in secrets, loaded into config)
|
||||
if (config["cline:clineAccountId"]) return true
|
||||
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
|
||||
@@ -712,6 +852,67 @@ async function checkAnyProviderConfigured(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a task exists in history
|
||||
* @returns The task history item if found, null otherwise
|
||||
*/
|
||||
function findTaskInHistory(taskId: string): HistoryItem | null {
|
||||
const taskHistory = StateManager.get().getGlobalStateKey("taskHistory") || []
|
||||
return taskHistory.find((item) => item.id === taskId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing task by ID
|
||||
* Loads the task and optionally prefills the input with a prompt
|
||||
*/
|
||||
async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
// Validate task exists
|
||||
const historyItem = findTaskInHistory(taskId)
|
||||
if (!historyItem) {
|
||||
printWarning(`Task not found: ${taskId}`)
|
||||
printInfo("Use 'cline history' to see available tasks.")
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
telemetryService.captureHostEvent("resume_task_command", options.initialPrompt ? "with_prompt" : "interactive")
|
||||
|
||||
// Apply shared task options (mode, model, thinking, yolo)
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
// Use plain text mode for non-interactive scenarios
|
||||
if (shouldUsePlainTextMode(options)) {
|
||||
return runTaskInPlainTextMode(ctx, options, {
|
||||
prompt: options.initialPrompt,
|
||||
taskId: taskId,
|
||||
})
|
||||
}
|
||||
|
||||
// Interactive mode: render the task view with the existing task
|
||||
let taskError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "task",
|
||||
taskId: taskId,
|
||||
verbose: options.verbose,
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
initialPrompt: options.initialPrompt || undefined,
|
||||
onError: () => {
|
||||
taskError = true
|
||||
},
|
||||
onWelcomeExit: () => {
|
||||
// User pressed Esc; Ink exits and cleanup handles process exit.
|
||||
},
|
||||
}),
|
||||
createInkCleanup(ctx, () => taskError),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show welcome prompt and wait for user input
|
||||
* If auth is not configured, show auth flow first
|
||||
@@ -732,16 +933,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)
|
||||
},
|
||||
)
|
||||
@@ -753,14 +952,18 @@ 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) => {
|
||||
// Check for ACP mode first - this takes precedence over everything else
|
||||
if (options.acp) {
|
||||
@@ -775,8 +978,18 @@ program
|
||||
// Always check for piped stdin content
|
||||
const stdinInput = await readStdinIfPiped()
|
||||
|
||||
// Error if stdin was piped but empty (e.g., `echo "" | cline`)
|
||||
if (stdinInput === "") {
|
||||
// Track whether stdin was actually piped (even if empty) vs not piped (null)
|
||||
// stdinInput === null means stdin wasn't piped (TTY or not FIFO/file)
|
||||
// stdinInput === "" means stdin was piped but empty
|
||||
// stdinInput has content means stdin was piped with data
|
||||
const stdinWasPiped = stdinInput !== null
|
||||
|
||||
// Error if stdin was piped but empty AND no prompt was provided
|
||||
// This handles:
|
||||
// - `echo "" | cline` -> error (empty stdin, no prompt)
|
||||
// - `cline "prompt"` in GitHub Actions -> OK (empty stdin ignored, has prompt)
|
||||
// - `cat file | cline "explain"` -> OK (has stdin AND prompt)
|
||||
if (stdinInput === "" && !prompt) {
|
||||
printWarning("Empty input received from stdin. Please provide content to process.")
|
||||
exit(1)
|
||||
}
|
||||
@@ -799,17 +1012,24 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
// Handle --taskId flag to resume an existing task
|
||||
if (options.taskId) {
|
||||
await resumeTask(options.taskId, {
|
||||
...options,
|
||||
initialPrompt: effectivePrompt,
|
||||
stdinWasPiped,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (effectivePrompt) {
|
||||
// Pass stdinWasPiped flag so runTask knows to use plain text mode
|
||||
await runTask(effectivePrompt, { ...options, stdinWasPiped: !!stdinInput })
|
||||
await runTask(effectivePrompt, { ...options, stdinWasPiped })
|
||||
} else {
|
||||
// Show welcome prompt if no prompt given
|
||||
await showWelcome(options)
|
||||
}
|
||||
})
|
||||
|
||||
// Background auto-update check (non-blocking)
|
||||
autoUpdateOnStartup(CLI_VERSION)
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Opens a URL in the user's default browser.
|
||||
* Uses dynamic import of the 'open' package to open URLs.
|
||||
*
|
||||
* @param url - The URL to open in the browser
|
||||
*/
|
||||
export async function openUrlInBrowser(url: string): Promise<void> {
|
||||
const { default: open } = await import("open")
|
||||
await open(url)
|
||||
}
|
||||
@@ -13,6 +13,6 @@ import { Fzf } from "fzf"
|
||||
*/
|
||||
export function fuzzyFilter<T>(items: readonly T[], query: string, selector: (item: T) => string): T[] {
|
||||
if (!query) return [...items]
|
||||
const fzf = new Fzf(items, { selector })
|
||||
return fzf.find(query).map((result) => result.item)
|
||||
const fzf = new Fzf(items as any, { selector } as any)
|
||||
return fzf.find(query).map((result) => result.item) as T[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { selectOutputMode } from "./mode-selection"
|
||||
|
||||
describe("selectOutputMode", () => {
|
||||
describe("interactive mode (Ink)", () => {
|
||||
it("should use interactive mode when both stdin and stdout are TTY", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(false)
|
||||
expect(result.reason).toBe("interactive")
|
||||
})
|
||||
})
|
||||
|
||||
describe("yolo flag", () => {
|
||||
it("should use plain text mode when --yolo flag is set", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
yolo: true,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("yolo_flag")
|
||||
})
|
||||
|
||||
it("should prioritize yolo over other flags", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: false,
|
||||
stdinIsTTY: false,
|
||||
stdinWasPiped: true,
|
||||
json: true,
|
||||
yolo: true,
|
||||
})
|
||||
expect(result.reason).toBe("yolo_flag")
|
||||
})
|
||||
})
|
||||
|
||||
describe("json flag", () => {
|
||||
it("should use plain text mode when --json flag is set", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
json: true,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("piped stdin", () => {
|
||||
it("should use plain text mode when stdin was piped (echo x | cline)", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: false, // piped stdin is not a TTY
|
||||
stdinWasPiped: true,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("piped_stdin")
|
||||
})
|
||||
|
||||
it("should use plain text mode when stdin was piped but empty (echo '' | cline 'prompt')", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: false,
|
||||
stdinWasPiped: true, // empty pipe still counts as piped
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("piped_stdin")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdin redirected (< /dev/null)", () => {
|
||||
it("should use plain text mode when stdin is redirected from /dev/null", () => {
|
||||
// cline "prompt" < /dev/null
|
||||
// stdin is not a TTY, but also not a FIFO/file, so stdinWasPiped=false
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: false, // redirected, not a TTY
|
||||
stdinWasPiped: false, // /dev/null is a character device, not FIFO
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("stdin_redirected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdout redirected", () => {
|
||||
it("should use plain text mode when stdout is redirected to file", () => {
|
||||
// cline "prompt" > output.txt
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: false,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("stdout_redirected")
|
||||
})
|
||||
|
||||
it("should use plain text mode when stdout is piped", () => {
|
||||
// cline "prompt" | grep something
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: false,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("stdout_redirected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitHub Actions scenarios", () => {
|
||||
it("should use plain text mode in GitHub Actions (stdin is empty FIFO)", () => {
|
||||
// In GitHub Actions: stdin is an empty FIFO pipe
|
||||
// stdinIsTTY=false, stdinWasPiped=true (FIFO detected)
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true, // GitHub Actions stdout is TTY-like
|
||||
stdinIsTTY: false,
|
||||
stdinWasPiped: true, // empty FIFO still counts as piped
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
})
|
||||
|
||||
it("should use plain text mode with --yolo in CI", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: false,
|
||||
stdinIsTTY: false,
|
||||
stdinWasPiped: false,
|
||||
yolo: true,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
expect(result.reason).toBe("yolo_flag")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("cline (no args, interactive terminal)", () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(false)
|
||||
})
|
||||
|
||||
it('cline "prompt" (prompt arg, interactive terminal)', () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(false)
|
||||
})
|
||||
|
||||
it('cat file | cline "explain"', () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: false,
|
||||
stdinWasPiped: true,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
})
|
||||
|
||||
it('cline --yolo "prompt"', () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
yolo: true,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
})
|
||||
|
||||
it('cline "prompt" < /dev/null', () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: true,
|
||||
stdinIsTTY: false,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
})
|
||||
|
||||
it('cline "prompt" > output.log', () => {
|
||||
const result = selectOutputMode({
|
||||
stdoutIsTTY: false,
|
||||
stdinIsTTY: true,
|
||||
stdinWasPiped: false,
|
||||
})
|
||||
expect(result.usePlainTextMode).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Mode selection logic for CLI - determines whether to use Ink (interactive) or plain text mode
|
||||
*
|
||||
* This is extracted as a pure function for testability. The decision tree:
|
||||
* - Plain text mode when output is redirected (stdout not TTY)
|
||||
* - Plain text mode when input is redirected (stdin not TTY) - Ink requires raw mode
|
||||
* - Plain text mode when stdin was piped (e.g., echo "x" | cline)
|
||||
* - Plain text mode when --json flag is used
|
||||
* - Plain text mode when --yolo flag is used
|
||||
* - Otherwise: Interactive Ink mode
|
||||
*/
|
||||
|
||||
export interface ModeSelectionInput {
|
||||
/** Is stdout connected to a TTY (interactive terminal)? */
|
||||
stdoutIsTTY: boolean
|
||||
/** Is stdin connected to a TTY (interactive terminal)? */
|
||||
stdinIsTTY: boolean
|
||||
/** Was stdin piped (FIFO or file), even if empty? */
|
||||
stdinWasPiped: boolean
|
||||
/** --json flag for machine-readable output */
|
||||
json?: boolean
|
||||
/** --yolo flag for auto-approve mode */
|
||||
yolo?: boolean
|
||||
}
|
||||
|
||||
export interface ModeSelectionResult {
|
||||
/** Use plain text mode instead of Ink */
|
||||
usePlainTextMode: boolean
|
||||
/** Reason for the mode selection (for telemetry/debugging) */
|
||||
reason: "interactive" | "yolo_flag" | "json" | "piped_stdin" | "stdin_redirected" | "stdout_redirected"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to use plain text mode or interactive Ink mode
|
||||
*
|
||||
* @param input - Environment and option flags
|
||||
* @returns Mode selection result with reason
|
||||
*/
|
||||
export function selectOutputMode(input: ModeSelectionInput): ModeSelectionResult {
|
||||
// Priority order matters - check most specific flags first
|
||||
|
||||
if (input.yolo) {
|
||||
return { usePlainTextMode: true, reason: "yolo_flag" }
|
||||
}
|
||||
|
||||
if (input.json) {
|
||||
return { usePlainTextMode: true, reason: "json" }
|
||||
}
|
||||
|
||||
if (input.stdinWasPiped) {
|
||||
return { usePlainTextMode: true, reason: "piped_stdin" }
|
||||
}
|
||||
|
||||
if (!input.stdinIsTTY) {
|
||||
return { usePlainTextMode: true, reason: "stdin_redirected" }
|
||||
}
|
||||
|
||||
if (!input.stdoutIsTTY) {
|
||||
return { usePlainTextMode: true, reason: "stdout_redirected" }
|
||||
}
|
||||
|
||||
return { usePlainTextMode: false, reason: "interactive" }
|
||||
}
|
||||
@@ -38,9 +38,9 @@ export async function fetchOpenRouterModels(): Promise<string[]> {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const data = (await response.json()) as { data?: OpenRouterModel[] }
|
||||
if (data?.data) {
|
||||
const models = (data.data as OpenRouterModel[]).map((m) => m.id).sort((a, b) => a.localeCompare(b))
|
||||
const models = data.data.map((m) => m.id).sort((a, b) => a.localeCompare(b))
|
||||
cachedModels = models
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
// @ts-expect-error - @vscode/ripgrep has no type declarations
|
||||
import { rgPath } from "@vscode/ripgrep"
|
||||
|
||||
const data = process.env.CLINE_DATA_DIR ?? path.join(os.homedir(), ".cline", "data")
|
||||
|
||||
+125
-31
@@ -1,54 +1,145 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import * as fs from "node:fs"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { readStdinIfPiped } from "./piped"
|
||||
|
||||
// Mock the fs module
|
||||
vi.mock("node:fs", () => ({
|
||||
readFileSync: vi.fn(),
|
||||
}))
|
||||
// Mock fs.fstatSync to simulate pipe detection
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual("node:fs")
|
||||
return {
|
||||
...actual,
|
||||
fstatSync: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("readStdinIfPiped", () => {
|
||||
const mockReadFileSync = fs.readFileSync as ReturnType<typeof vi.fn>
|
||||
let originalIsTTY: boolean | undefined
|
||||
let originalStdin: typeof process.stdin
|
||||
let mockStdin: EventEmitter & {
|
||||
isTTY?: boolean
|
||||
setEncoding: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalIsTTY = process.stdin.isTTY
|
||||
originalStdin = process.stdin
|
||||
|
||||
// Create a mock stdin
|
||||
mockStdin = Object.assign(new EventEmitter(), {
|
||||
isTTY: undefined as boolean | undefined,
|
||||
setEncoding: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
})
|
||||
|
||||
// Default: simulate a real pipe (FIFO)
|
||||
vi.mocked(fs.fstatSync).mockReturnValue({
|
||||
isFIFO: () => true,
|
||||
isFile: () => false,
|
||||
} as fs.Stats)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// Restore original isTTY value
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
// Restore original stdin
|
||||
Object.defineProperty(process, "stdin", {
|
||||
value: originalStdin,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
function setTTY(value: boolean | undefined) {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
value,
|
||||
mockStdin.isTTY = value
|
||||
Object.defineProperty(process, "stdin", {
|
||||
value: mockStdin,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
function emitData(data: string) {
|
||||
mockStdin.emit("data", data)
|
||||
}
|
||||
|
||||
function emitEnd() {
|
||||
mockStdin.emit("end")
|
||||
}
|
||||
|
||||
function emitError(error: Error) {
|
||||
mockStdin.emit("error", error)
|
||||
}
|
||||
|
||||
describe("TTY detection", () => {
|
||||
it("should return null when stdin is a TTY (interactive terminal)", async () => {
|
||||
setTTY(true)
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
|
||||
setTTY(false)
|
||||
mockReadFileSync.mockReturnValue("")
|
||||
|
||||
const promise = readStdinIfPiped()
|
||||
emitEnd()
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe("")
|
||||
expect(mockStdin.setEncoding).toHaveBeenCalledWith("utf8")
|
||||
expect(mockStdin.resume).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdin type detection (fstat)", () => {
|
||||
it("should return null when stdin is not a FIFO or file (spawned without TTY)", async () => {
|
||||
setTTY(false)
|
||||
// Simulate a character device or socket (not a pipe)
|
||||
vi.mocked(fs.fstatSync).mockReturnValue({
|
||||
isFIFO: () => false,
|
||||
isFile: () => false,
|
||||
} as fs.Stats)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(0, "utf8")
|
||||
})
|
||||
|
||||
it("should return null when fstatSync throws (detached stdin)", async () => {
|
||||
setTTY(false)
|
||||
vi.mocked(fs.fstatSync).mockImplementation(() => {
|
||||
throw new Error("EBADF: bad file descriptor")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should read from stdin when it is a FIFO (pipe)", async () => {
|
||||
setTTY(false)
|
||||
vi.mocked(fs.fstatSync).mockReturnValue({
|
||||
isFIFO: () => true,
|
||||
isFile: () => false,
|
||||
} as fs.Stats)
|
||||
|
||||
const promise = readStdinIfPiped()
|
||||
emitData("piped content")
|
||||
emitEnd()
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe("piped content")
|
||||
})
|
||||
|
||||
it("should read from stdin when it is a regular file (redirected)", async () => {
|
||||
setTTY(false)
|
||||
vi.mocked(fs.fstatSync).mockReturnValue({
|
||||
isFIFO: () => false,
|
||||
isFile: () => true,
|
||||
} as fs.Stats)
|
||||
|
||||
const promise = readStdinIfPiped()
|
||||
emitData("file content")
|
||||
emitEnd()
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe("file content")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,14 +167,14 @@ describe("readStdinIfPiped", () => {
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: null,
|
||||
description: "should return null for empty input",
|
||||
expected: "",
|
||||
description: "should return empty string for empty input",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n \t \n ",
|
||||
expected: null,
|
||||
description: "should return null for whitespace-only input",
|
||||
expected: "",
|
||||
description: "should return empty string for whitespace-only input",
|
||||
},
|
||||
{
|
||||
name: "leading and trailing whitespace",
|
||||
@@ -127,25 +218,25 @@ describe("readStdinIfPiped", () => {
|
||||
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
|
||||
setTTY(false)
|
||||
const data = Array.isArray(input) ? input.join("\n") : input
|
||||
mockReadFileSync.mockReturnValue(data)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
const promise = readStdinIfPiped()
|
||||
emitData(data)
|
||||
emitEnd()
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should return null on fs.readFileSync error and fall back to async", async () => {
|
||||
it("should return null on stdin error", async () => {
|
||||
setTTY(false)
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("EAGAIN: resource temporarily unavailable")
|
||||
})
|
||||
|
||||
// The async fallback will timeout since we can't easily mock process.stdin events
|
||||
// But we can verify it doesn't throw
|
||||
const result = await readStdinIfPiped()
|
||||
// Result will be null because async path times out with no data
|
||||
const promise = readStdinIfPiped()
|
||||
emitError(new Error("EAGAIN: resource temporarily unavailable"))
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -188,9 +279,12 @@ describe("readStdinIfPiped", () => {
|
||||
useCases.forEach(({ name, input, expected }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
setTTY(false)
|
||||
mockReadFileSync.mockReturnValue(input)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
const promise = readStdinIfPiped()
|
||||
emitData(input)
|
||||
emitEnd()
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
+18
-1
@@ -1,3 +1,5 @@
|
||||
import * as fs from "node:fs"
|
||||
|
||||
/**
|
||||
* Read piped input from stdin (non-blocking)
|
||||
*
|
||||
@@ -9,11 +11,26 @@
|
||||
* for EOF which signals that the previous command has finished writing.
|
||||
*/
|
||||
export async function readStdinIfPiped(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive) or piped
|
||||
// Check if stdin is a TTY (interactive) - no piped input
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
// When spawned as a child process without TTY (e.g., from spawn()), stdin.isTTY
|
||||
// is false but there's no actual piped input. Check if stdin is a real pipe/file
|
||||
// by testing if we can get stats on fd 0. A real pipe will have stats, while
|
||||
// a detached stdin may throw or have unusual properties.
|
||||
try {
|
||||
const stats = fs.fstatSync(0)
|
||||
// If it's not a FIFO (pipe) or regular file, treat as no input
|
||||
if (!stats.isFIFO() && !stats.isFile()) {
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
// If we can't stat stdin, treat as no input
|
||||
return null
|
||||
}
|
||||
|
||||
// Use async approach - more reliable for piped input from other commands
|
||||
// The synchronous readFileSync(0) can fail with EAGAIN when the pipe
|
||||
// isn't ready yet (common when piping from another cline command)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -12,18 +12,24 @@
|
||||
// Console output is intentional here for plain text mode
|
||||
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
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
|
||||
prompt: string
|
||||
/** Prompt for new task or message to send to resumed task */
|
||||
prompt?: string
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,17 +45,39 @@ export interface PlainTextTaskOptions {
|
||||
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
|
||||
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
|
||||
|
||||
let completionResolve: () => void
|
||||
let completionResolve: (reason?: any) => void
|
||||
let completionReject: (reason?: any) => void
|
||||
const completionPromise = new Promise<void>((res, rej) => {
|
||||
const completionPromise = new Promise<string>((res, rej) => {
|
||||
completionResolve = res
|
||||
completionReject = rej
|
||||
})
|
||||
|
||||
let hasError = false
|
||||
let hasEmittedTaskStarted = false
|
||||
// Track which messages have been processed (by timestamp)
|
||||
const processedMessages = new Map<number, string>()
|
||||
|
||||
const isViewTaskOnly = Boolean(options.taskId) && !prompt
|
||||
|
||||
// When resuming a task, we need to ignore completion_result messages that existed
|
||||
// before we sent our new prompt. This timestamp marks the cutoff - only completion
|
||||
// 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
|
||||
@@ -67,8 +95,12 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
processedMessages.set(ts, message.text ?? "")
|
||||
|
||||
// Check for completion (only on non-partial messages)
|
||||
// When resuming a task, only consider completion_result messages that appeared
|
||||
// AFTER we sent our resume message (ts > completionCutoffTs)
|
||||
if (message.say === "completion_result" || message.ask === "completion_result") {
|
||||
completionResolve()
|
||||
if (isViewTaskOnly || ts > completionCutoffTs) {
|
||||
completionResolve()
|
||||
}
|
||||
} else if (message.say === "error" || message.ask === "api_req_failed") {
|
||||
completionReject(message.text ?? "message.say error || message.ask api_req_failed")
|
||||
}
|
||||
@@ -99,11 +131,36 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
)
|
||||
|
||||
try {
|
||||
// Start the task
|
||||
await controller.initTask(prompt, imageDataUrls)
|
||||
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])
|
||||
// Either resume an existing task or start a new one
|
||||
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) {
|
||||
// Wait a moment for the task to fully load
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Send the prompt as a response to any pending ask, or as a new message
|
||||
await controller.task.handleWebviewAskResponse("messageResponse", prompt)
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getProviderModelIdKey, ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { BedrockConfig } from "../components/BedrockSetup"
|
||||
import { getDefaultModelId } from "../components/ModelPicker"
|
||||
|
||||
export interface ApplyProviderConfigOptions {
|
||||
@@ -75,3 +76,67 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
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, customModelBaseId, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
actModeApiProvider: "bedrock",
|
||||
planModeApiProvider: "bedrock",
|
||||
awsAuthentication: bedrockConfig.awsAuthentication,
|
||||
awsRegion: bedrockConfig.awsRegion,
|
||||
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
|
||||
}
|
||||
|
||||
// Add model ID
|
||||
const finalModelId = modelId || getDefaultModelId("bedrock")
|
||||
if (finalModelId) {
|
||||
const actModelKey = getProviderModelIdKey("bedrock" as ApiProvider, "act")
|
||||
const planModelKey = getProviderModelIdKey("bedrock" as ApiProvider, "plan")
|
||||
if (actModelKey) config[actModelKey] = finalModelId
|
||||
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
|
||||
if (bedrockConfig.awsSecretKey) config.awsSecretKey = bedrockConfig.awsSecretKey
|
||||
if (bedrockConfig.awsSessionToken) config.awsSessionToken = bedrockConfig.awsSessionToken
|
||||
|
||||
// Save via StateManager
|
||||
stateManager.setApiConfiguration(config as Record<string, string>)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler on active task if one exists
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* Used by both UI components and CLI commands
|
||||
*/
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import providersData from "@/shared/providers/providers.json"
|
||||
import type { RemoteConfigFields } from "@/shared/storage/state-keys"
|
||||
|
||||
// Create a lookup map from provider value to display label
|
||||
const providerLabels: Record<string, string> = Object.fromEntries(
|
||||
@@ -17,7 +20,7 @@ const providerOrder: string[] = providersData.list.map((p: { value: string }) =>
|
||||
* Providers that are not supported in CLI.
|
||||
* - vscode-lm: Requires VS Code's Language Model API (see ENG-1490 for OAuth-based support)
|
||||
*/
|
||||
export const CLI_EXCLUDED_PROVIDERS = new Set<string>(["vscode-lm"])
|
||||
const CLI_EXCLUDED_PROVIDERS = new Set<string>(["vscode-lm"])
|
||||
|
||||
/**
|
||||
* Get the display label for a provider ID
|
||||
@@ -29,7 +32,7 @@ export function getProviderLabel(providerId: string): string {
|
||||
/**
|
||||
* Get the ordered list of all provider IDs (from providers.json)
|
||||
*/
|
||||
export function getProviderOrder(): string[] {
|
||||
function getProviderOrder(): string[] {
|
||||
return providerOrder
|
||||
}
|
||||
|
||||
@@ -46,3 +49,19 @@ export function getValidCliProviders(): string[] {
|
||||
export function isValidCliProvider(providerId: string): boolean {
|
||||
return providerOrder.includes(providerId) && !CLI_EXCLUDED_PROVIDERS.has(providerId)
|
||||
}
|
||||
|
||||
const getValidProviders = (remoteConfig: Partial<RemoteConfigFields> | undefined) => {
|
||||
if (remoteConfig?.remoteConfiguredProviders?.length) {
|
||||
return remoteConfig.remoteConfiguredProviders
|
||||
}
|
||||
|
||||
return getProviderOrder().filter((p: string) => !CLI_EXCLUDED_PROVIDERS.has(p))
|
||||
}
|
||||
|
||||
export const useValidProviders = () => {
|
||||
const remoteConfig = StateManager.get().getRemoteConfigSettings()
|
||||
|
||||
return useMemo(() => {
|
||||
return getValidProviders(remoteConfig)
|
||||
}, [remoteConfig])
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Wait for a condition to become truthy, with a timeout.
|
||||
* Uses Promise.race for clean timeout handling instead of polling.
|
||||
*
|
||||
* @param condition - Function that returns the value to check (truthy = done)
|
||||
* @param timeoutMs - Maximum time to wait in milliseconds
|
||||
* @param pollIntervalMs - How often to check the condition (default: 100ms)
|
||||
* @returns The truthy value if condition is met, or undefined if timeout
|
||||
*/
|
||||
export async function waitFor<T>(
|
||||
condition: () => T | undefined | null,
|
||||
timeoutMs: number,
|
||||
pollIntervalMs: number = 100,
|
||||
): Promise<T | undefined> {
|
||||
// Check immediately first
|
||||
const immediate = condition()
|
||||
if (immediate) {
|
||||
return immediate
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const intervalId = setInterval(() => {
|
||||
const result = condition()
|
||||
if (result) {
|
||||
clearInterval(intervalId)
|
||||
clearTimeout(timeoutId)
|
||||
resolve(result)
|
||||
}
|
||||
}, pollIntervalMs)
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(intervalId)
|
||||
resolve(undefined)
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"jsx": "react",
|
||||
"jsxFactory": "React.createElement",
|
||||
"lib": [
|
||||
"es2022"
|
||||
"es2022",
|
||||
"DOM"
|
||||
],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: "ACP: Editor Integrations"
|
||||
description: "Use Cline in JetBrains, Neovim, Zed, and other editors via the Agent Client Protocol"
|
||||
---
|
||||
|
||||
|
||||
Cline CLI supports the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/), an open standard that enables AI coding agents to work across different editors and IDEs. This means you can use the full Cline agent—with all its capabilities including Skills, Hooks, and MCP integrations—in your preferred development environment.
|
||||
|
||||
## Why ACP?
|
||||
|
||||
- **Editor flexibility**: Use Cline in JetBrains, Neovim, Zed, or any ACP-compatible editor
|
||||
- **No feature compromises**: Full access to Cline's capabilities regardless of editor
|
||||
- **Team consistency**: Same AI assistant across different developer workflows
|
||||
- **Open standard**: Built on Zed's open Agent Client Protocol specification
|
||||
|
||||
## JetBrains IDEs
|
||||
|
||||
[JetBrains](https://www.jetbrains.com) IDEs include IntelliJ IDEA, PyCharm, WebStorm, and more. They offer built-in AI Assistant with ACP support.
|
||||
|
||||
<Note>
|
||||
**Recommended: Native JetBrains Plugin**
|
||||
|
||||
For the best JetBrains experience, install the [native Cline plugin](/getting-started/installing-cline#jetbrains-ides) from the JetBrains Marketplace. It provides full IDE integration and the complete Cline experience.
|
||||
|
||||
The ACP setup below is an alternative way to use Cline CLI features in JetBrains IDEs.
|
||||
</Note>
|
||||
|
||||
Alternatively, you can run Cline CLI in IntelliJ IDEA, PyCharm, WebStorm, and all other JetBrains IDEs through their built-in AI Assistant with ACP support.
|
||||
|
||||
<video
|
||||
src="https://storage.googleapis.com/cline_public_images/cline-acp-jetbrains.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{ width: "100%", borderRadius: "8px", marginTop: "16px", marginBottom: "16px" }}
|
||||
/>
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install Cline CLI** (if not already installed):
|
||||
```bash
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
2. **Authenticate with Cline**:
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
3. **Configure JetBrains AI Assistant**:
|
||||
- Open your JetBrains IDE
|
||||
- Navigate to `Settings | Tools | AI Assistant | Agents`
|
||||
- Click "Add Custom Agent"
|
||||
- This opens/creates `~/.jetbrains/acp.json`
|
||||
|
||||
4. **Add Cline to `acp.json`**:
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Cline": {
|
||||
"command": "cline",
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Use Cline**:
|
||||
- Open the AI Chat tool window
|
||||
- Select "Cline" from the agent dropdown
|
||||
- Start coding with Cline in your JetBrains IDE!
|
||||
|
||||
<Tip>
|
||||
JetBrains AI Assistant can expose its built-in MCP server to Cline, giving Cline access to IDE-specific tools and context.
|
||||
</Tip>
|
||||
|
||||
## Neovim
|
||||
|
||||
[Neovim](https://neovim.io) is a hyperextensible Vim-based text editor loved by developers for its speed and flexibility. Use Cline in Neovim through the [agentic.nvim](https://github.com/carlos-algms/agentic.nvim) or [avante.nvim](https://github.com/yetone/avante.nvim) plugins, which provide ACP integration.
|
||||
|
||||
<video
|
||||
src="https://storage.googleapis.com/cline_public_images/cline-acp-neovim-avante.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{ width: "100%", borderRadius: "8px", marginTop: "16px", marginBottom: "16px" }}
|
||||
/>
|
||||
|
||||
### Setup with agentic.nvim
|
||||
|
||||
1. **Install Cline CLI** (if not already installed):
|
||||
```bash
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
2. **Authenticate with Cline**:
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
3. **Install agentic.nvim** using lazy.nvim:
|
||||
```lua
|
||||
{
|
||||
"carlos-algms/agentic.nvim",
|
||||
opts = {
|
||||
provider = "cline-acp",
|
||||
acp_providers = {
|
||||
["cline-acp"] = {
|
||||
command = "cline",
|
||||
args = {"--acp"},
|
||||
},
|
||||
},
|
||||
},
|
||||
keys = {
|
||||
{"<C-\\>", function() require("agentic").toggle() end, mode={"n","v","i"}, desc="Toggle Cline Chat"},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
4. **Use Cline**:
|
||||
- Press `<C-\>` to toggle Cline chat
|
||||
- Start coding with Cline in Neovim!
|
||||
|
||||
### Setup with avante.nvim
|
||||
|
||||
Follow the [avante.nvim documentation](https://github.com/yetone/avante.nvim) for configuring external ACP agents and point it to `cline --acp`.
|
||||
|
||||
## Zed
|
||||
|
||||
[Zed](https://zed.dev) is a high-performance, multiplayer code editor built from the ground up for speed and collaboration. Zed's team created the Agent Client Protocol, making Cline a natural fit for this editor.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install Cline CLI** (if not already installed):
|
||||
```bash
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
2. **Authenticate with Cline**:
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
3. **Configure Zed**:
|
||||
- Open Zed settings (`Cmd/Ctrl + ,`)
|
||||
- Add Cline to your `settings.json`:
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Cline": {
|
||||
"type": "custom",
|
||||
"command": "cline",
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Use Cline**:
|
||||
- Open the AI assistant panel
|
||||
- Select "Cline" from the agent dropdown
|
||||
- Start coding with Cline in Zed!
|
||||
|
||||
## Other Editors
|
||||
|
||||
Any editor that supports the Agent Client Protocol can run Cline. Check your editor's documentation for ACP configuration instructions, then point it to:
|
||||
|
||||
```bash
|
||||
cline --acp
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent not appearing
|
||||
|
||||
- Ensure Cline CLI is installed globally: `npm i -g cline`
|
||||
- Verify authentication: `cline auth`
|
||||
- Check that `cline --acp` runs without errors
|
||||
- Restart your editor after configuration changes
|
||||
|
||||
### Permission errors
|
||||
|
||||
If Cline can't access files or run commands:
|
||||
- Check that your editor's ACP integration passes the correct working directory
|
||||
- Verify file permissions in your project
|
||||
- Ensure Cline has approval settings configured correctly
|
||||
|
||||
### Connection issues
|
||||
|
||||
- Make sure no other Cline instance is using the same configuration directory
|
||||
- Check editor logs for ACP-related errors
|
||||
- Try running `cline --acp` manually to test the connection
|
||||
|
||||
## Learn More
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="CLI Overview" icon="terminal" href="/cline-cli/overview">
|
||||
Learn about Cline CLI's core capabilities and use cases.
|
||||
</Card>
|
||||
|
||||
<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="/customization/skills">
|
||||
Understand how Cline's Skills work across all editors via ACP.
|
||||
</Card>
|
||||
|
||||
<Card title="Hooks" icon="link" href="/customization/hooks">
|
||||
Learn how to enforce policies with Hooks in any editor.
|
||||
</Card>
|
||||
</Columns>
|
||||
+374
-432
@@ -1,482 +1,424 @@
|
||||
---
|
||||
title: "CLI Reference"
|
||||
description: "Complete command reference for Cline CLI including configuration, instance management, and task commands"
|
||||
description: "Complete command reference for Cline CLI including all commands, flags, and configuration options"
|
||||
---
|
||||
|
||||
Complete command reference for Cline CLI. Use this for detailed documentation on all commands, options, and configuration.
|
||||
|
||||
For quick help in your terminal:
|
||||
This page documents all available commands, flags, and configuration options for Cline CLI. For quick help in your terminal, use:
|
||||
|
||||
```bash
|
||||
cline --help # Show all commands
|
||||
cline task --help # Show task-specific commands
|
||||
man cline # View the full manual page
|
||||
cline task --help # Show task command options
|
||||
cline auth --help # Show auth command options
|
||||
man cline # View the full manual page (if installed)
|
||||
```
|
||||
|
||||
## Manual Page
|
||||
## Synopsis
|
||||
|
||||
The complete manual page for the Cline CLI:
|
||||
|
||||
```
|
||||
CLINE(1) User Commands CLINE(1)
|
||||
|
||||
NAME
|
||||
cline - orchestrate and interact with Cline AI coding agents
|
||||
|
||||
SYNOPSIS
|
||||
cline [prompt] [options]
|
||||
|
||||
cline command [subcommand] [options] [arguments]
|
||||
|
||||
DESCRIPTION
|
||||
Try: cat README.md | cline "Summarize this for me:"
|
||||
|
||||
cline is a command-line interface for orchestrating multiple Cline AI
|
||||
coding agents. Cline is an autonomous AI agent who can read, write,
|
||||
and execute code across your projects. He operates through a
|
||||
client-server architecture where Cline Core runs as a standalone
|
||||
service, and the CLI acts as a scriptable interface for managing tasks,
|
||||
instances, and agent interactions.
|
||||
|
||||
The CLI is designed for both interactive use and automation, making it
|
||||
ideal for CI/CD pipelines, parallel task execution, and terminal-based
|
||||
workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to
|
||||
the same Cline Core instance, enabling seamless task handoff between
|
||||
environments.
|
||||
|
||||
MODES OF OPERATION
|
||||
Instant Task Mode
|
||||
The simplest invocation: cline "prompt here" immediately spawns
|
||||
an instance, creates a task, and enters chat mode. This is
|
||||
equivalent to running cline instance new && cline task new &&
|
||||
cline task chat in sequence.
|
||||
|
||||
Subcommand Mode
|
||||
Advanced usage with explicit control: cline <command>
|
||||
[subcommand] [options] provides fine-grained control over
|
||||
instances, tasks, authentication, and configuration.
|
||||
|
||||
AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
|
||||
ACT MODE
|
||||
Cline actively uses tools to accomplish tasks. He can read
|
||||
files, write code, execute commands, use a headless browser, and
|
||||
more. This is the default mode for task execution.
|
||||
|
||||
PLAN MODE
|
||||
Cline gathers information and creates a detailed plan before
|
||||
implementation. He explores the codebase, asks clarifying
|
||||
questions, and presents a strategy for user approval before
|
||||
switching to ACT MODE.
|
||||
|
||||
INSTANT TASK OPTIONS
|
||||
When using the instant task syntax cline "prompt" the following options
|
||||
are available:
|
||||
|
||||
-o, --oneshot
|
||||
Full autonomous mode. Cline completes the task and stops
|
||||
following after completion. Example: cline -o "what's 6 + 8?"
|
||||
|
||||
-s, --setting setting value
|
||||
Override a setting for this task
|
||||
|
||||
-y, --no-interactive, --yolo
|
||||
Enable fully autonomous mode. Disables all interactivity:
|
||||
|
||||
• ask_followup_question tool is disabled
|
||||
|
||||
• attempt_completion happens automatically
|
||||
|
||||
• execute_command runs in non-blocking mode with timeout
|
||||
|
||||
• PLAN MODE automatically switches to ACT MODE
|
||||
|
||||
-m, --mode mode
|
||||
Starting mode. Options: act (default), plan
|
||||
|
||||
-w, --workspace path
|
||||
Additional workspace paths. Can be specified multiple times to
|
||||
include multiple directories. The current working directory is
|
||||
always included as the first workspace. Example: cline -w
|
||||
/path/to/other/project "refactor shared code"
|
||||
|
||||
GLOBAL OPTIONS
|
||||
These options apply to all subcommands:
|
||||
|
||||
-F, --output-format format
|
||||
Output format. Options: rich (default), json, plain
|
||||
|
||||
-h, --help
|
||||
Display help information for the command.
|
||||
|
||||
-v, --verbose
|
||||
Enable verbose output for debugging.
|
||||
|
||||
COMMANDS
|
||||
Authentication
|
||||
cline auth [provider] [key]
|
||||
|
||||
cline a [provider] [key]
|
||||
Configure authentication for AI model providers. Launches an
|
||||
interactive wizard if no arguments provided. If provider is
|
||||
specified without a key, prompts for the key or launches the
|
||||
appropriate OAuth flow.
|
||||
|
||||
Instance Management
|
||||
Cline Core instances are independent agent processes that can run in
|
||||
the background. Multiple instances can run simultaneously, enabling
|
||||
parallel task execution.
|
||||
|
||||
cline instance
|
||||
|
||||
cline i
|
||||
Display instance management help.
|
||||
|
||||
cline instance new [-d|--default]
|
||||
|
||||
cline i n [-d|--default]
|
||||
Spawn a new Cline Core instance. Use --default to set it as
|
||||
the default instance for subsequent commands.
|
||||
|
||||
cline instance list
|
||||
|
||||
cline i l
|
||||
List all running Cline Core instances with their addresses and
|
||||
status.
|
||||
|
||||
cline instance default address
|
||||
|
||||
cline i d address
|
||||
Set the default instance to avoid specifying --address in task
|
||||
commands.
|
||||
|
||||
cline instance kill address [-a|--all]
|
||||
|
||||
cline i k address [-a|--all]
|
||||
Terminate a Cline Core instance. Use --all to kill all running
|
||||
instances.
|
||||
|
||||
Task Management
|
||||
Tasks represent individual work items that Cline executes. Tasks
|
||||
maintain conversation history, checkpoints, and settings.
|
||||
|
||||
cline task [-a|--address ADDR]
|
||||
|
||||
cline t [-a|--address ADDR]
|
||||
Display task management help. The --address flag specifies
|
||||
which Cline Core instance to use (e.g., localhost:50052).
|
||||
|
||||
cline task new prompt [options]
|
||||
|
||||
cline t n prompt [options]
|
||||
Create a new task in the default or specified instance.
|
||||
Options:
|
||||
|
||||
-s, --setting setting value
|
||||
Set task-specific settings
|
||||
|
||||
-y, --no-interactive, --yolo
|
||||
Enable autonomous mode
|
||||
|
||||
-m, --mode mode
|
||||
Starting mode (act or plan)
|
||||
|
||||
cline task open task-id [options]
|
||||
|
||||
cline t o task-id [options]
|
||||
Resume a previous task from history. Accepts the same options
|
||||
as task new.
|
||||
|
||||
cline task list
|
||||
|
||||
cline t l
|
||||
List all tasks in history with their id and snippet
|
||||
|
||||
cline task chat
|
||||
|
||||
cline t c
|
||||
Enter interactive chat mode for the current task. Allows
|
||||
back-and-forth conversation with Cline.
|
||||
|
||||
cline task send [message] [options]
|
||||
|
||||
cline t s [message] [options]
|
||||
Send a message to Cline. If no message is provided, reads from
|
||||
stdin. Options:
|
||||
|
||||
-a, --approve
|
||||
Approve Cline's proposed action
|
||||
|
||||
-d, --deny
|
||||
Deny Cline's proposed action
|
||||
|
||||
-f, --file FILE
|
||||
Attach a file to the message
|
||||
|
||||
-y, --no-interactive, --yolo
|
||||
Enable autonomous mode
|
||||
|
||||
-m, --mode mode
|
||||
Switch mode (act or plan)
|
||||
|
||||
cline task view [-f|--follow] [-c|--follow-complete]
|
||||
|
||||
cline t v [-f|--follow] [-c|--follow-complete]
|
||||
Display the current conversation. Use --follow to stream
|
||||
updates in real-time, or --follow-complete to follow until task
|
||||
completion.
|
||||
|
||||
cline task restore checkpoint
|
||||
|
||||
cline t r checkpoint
|
||||
Restore the task to a previous checkpoint state.
|
||||
|
||||
cline task pause
|
||||
|
||||
cline t p
|
||||
Pause task execution.
|
||||
|
||||
Configuration
|
||||
Configuration can be set globally. Override these global settings for
|
||||
a task using the --setting flag
|
||||
|
||||
cline config
|
||||
|
||||
cline c
|
||||
|
||||
cline config set key value
|
||||
|
||||
cline c s key value
|
||||
Set a configuration variable.
|
||||
|
||||
cline config get key
|
||||
|
||||
cline c g key
|
||||
Read a configuration variable.
|
||||
|
||||
cline config list
|
||||
|
||||
cline c l
|
||||
List all configuration variables and their values.
|
||||
|
||||
Context Window Configuration
|
||||
For local model providers, you can configure the context window size:
|
||||
|
||||
Ollama
|
||||
cline config s ollama-api-options-ctx-num=32768
|
||||
|
||||
LM Studio
|
||||
cline config s lm-studio-max-tokens=32768
|
||||
|
||||
For other providers (Anthropic, OpenRouter, etc.), the context window
|
||||
is defined per model in the model metadata and is not user-settable.
|
||||
Cline uses each model's built-in context limits automatically.
|
||||
|
||||
TASK SETTINGS
|
||||
Task settings are persisted in the ~/.cline/x/tasks directory. When
|
||||
resuming a task with cline task open, task settings are automatically
|
||||
restored.
|
||||
|
||||
Common settings include:
|
||||
|
||||
yolo Enable autonomous mode (true/false)
|
||||
|
||||
mode Starting mode (act/plan)
|
||||
|
||||
hooks_enabled
|
||||
Enable or disable hooks for the task (true/false)
|
||||
|
||||
HOOKS INTEGRATION
|
||||
Hooks let you inject custom logic into Cline's workflow at key moments.
|
||||
They can validate operations before they execute, monitor tool usage,
|
||||
and shape AI decisions. This allows you to integrate hooks into
|
||||
automated workflows, CI/CD pipelines, and headless task execution.
|
||||
|
||||
Enable hooks for a task:
|
||||
|
||||
cline "prompt" -s hooks_enabled=true
|
||||
|
||||
Configure hooks globally:
|
||||
|
||||
cline config set hooks-enabled=true
|
||||
cline config get hooks-enabled
|
||||
|
||||
Note: Hooks in the CLI are only supported on macOS and Linux.
|
||||
|
||||
For complete hooks documentation, see:
|
||||
<https://docs.cline.bot/features/hooks/index>
|
||||
|
||||
NOTES & EXAMPLES
|
||||
The cline task send and cline task new commands support reading from
|
||||
stdin, enabling powerful pipeline compositions:
|
||||
|
||||
cat requirements.txt | cline task send
|
||||
echo "Refactor this code" | cline -y
|
||||
|
||||
Instance Management
|
||||
Manage multiple Cline instances:
|
||||
|
||||
# Start a new instance and make it default
|
||||
cline instance new --default
|
||||
|
||||
# List all running instances
|
||||
cline instance list
|
||||
|
||||
# Kill a specific instance
|
||||
cline instance kill localhost:50052
|
||||
|
||||
# Kill all CLI instances
|
||||
cline instance kill --all-cli
|
||||
|
||||
Task History
|
||||
Work with task history:
|
||||
|
||||
# List previous tasks
|
||||
cline task list
|
||||
|
||||
# Resume a previous task
|
||||
cline task open 1760501486669
|
||||
|
||||
# View conversation history
|
||||
cline task view
|
||||
|
||||
# Start interactive chat with this task
|
||||
cline task chat
|
||||
|
||||
ARCHITECTURE
|
||||
Cline operates on a three-layer architecture:
|
||||
|
||||
Presentation Layer
|
||||
User interfaces (CLI, VSCode, JetBrains) that connect to Cline
|
||||
Core via gRPC
|
||||
|
||||
Cline Core
|
||||
The autonomous agent service handling task management, AI model
|
||||
integration, state management, tool orchestration, and real-time
|
||||
streaming updates
|
||||
|
||||
Host Provider Layer
|
||||
Environment-specific integrations (VSCode APIs, JetBrains APIs,
|
||||
shell APIs) that Cline Core uses to interact with the host
|
||||
system
|
||||
|
||||
BUGS
|
||||
Report bugs at: <https://github.com/cline/cline/issues>
|
||||
|
||||
For real-time help, join the Discord community at:
|
||||
<https://discord.gg/cline>
|
||||
|
||||
SEE ALSO
|
||||
Full documentation: <https://docs.cline.bot>
|
||||
|
||||
AUTHORS
|
||||
Cline is developed by the Cline Bot Inc. and the open source community.
|
||||
|
||||
COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
|
||||
```bash
|
||||
cline [prompt] [options]
|
||||
cline <command> [options] [arguments]
|
||||
```
|
||||
|
||||
## JSON output (-F json)
|
||||
## Global Options
|
||||
|
||||
When you run a command with `-F json` (or `--output-format json`), Cline prints each client message as JSON.
|
||||
These options work with any command:
|
||||
|
||||
### ClineMessage schema
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--config <path>` | Use a custom configuration directory instead of `~/.cline/data/` |
|
||||
| `-c, --cwd <path>` | Set the working directory for the task |
|
||||
| `-v, --verbose` | Show detailed output including model reasoning |
|
||||
| `--help` | Show help for the command |
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | `"ask" or "say"` | Yes | Top-level message category. |
|
||||
| `text` | `string` | Yes | Human-readable message content. |
|
||||
| `ts` | `number` | Yes | Unix epoch timestamp in milliseconds. |
|
||||
| `reasoning` | `string` | No | Omitted when empty. |
|
||||
| `say` | `string` | No | Omitted when empty. Present when `type` is `"say"`. |
|
||||
| `ask` | `string` | No | Omitted when empty. Present when `type` is `"ask"`. |
|
||||
| `partial` | `boolean` | No | Omitted when false. `true` for streaming updates. |
|
||||
| `images` | `string[]` | No | Omitted when empty. Image URIs when included with a message. |
|
||||
| `files` | `string[]` | No | Omitted when empty. File paths when attached to a message. |
|
||||
| `lastCheckpointHash` | `string` | No | Omitted when empty. Git checkpoint hash when available. |
|
||||
| `isCheckpointCheckedOut` | `boolean` | No | Omitted when false. `true` if Cline checked out a checkpoint. |
|
||||
| `isOperationOutsideWorkspace` | `boolean` | No | Omitted when false. `true` if an operation happened outside the workspace. |
|
||||
## Modes of Operation
|
||||
|
||||
<Note>
|
||||
Most fields are optional and omitted when empty. If you parse this output, treat missing fields as “not present”, not as empty strings.
|
||||
</Note>
|
||||
Cline CLI automatically detects the best output mode based on how you invoke it:
|
||||
|
||||
### Example
|
||||
| Mode | When Activated | Description |
|
||||
|------|----------------|-------------|
|
||||
| **Interactive** | `cline` with no args, TTY connected | Rich terminal UI with real-time streaming, keyboard shortcuts, and visual feedback. |
|
||||
| **Task** | `cline "prompt"` with TTY connected | Interactive UI starts immediately with your task. |
|
||||
| **Plain Text** | stdin piped, stdout redirected, or `--yolo`/`--json` flags | Clean text output without UI, suitable for scripting and CI/CD. |
|
||||
|
||||
## Agent Behavior
|
||||
|
||||
Cline operates in two primary modes that control how it approaches tasks:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Act Mode** (default) | Cline actively uses tools to accomplish tasks. It can read files, write code, execute commands, use a headless browser, and more. |
|
||||
| **Plan Mode** | Cline gathers information and creates a detailed plan before implementation. It explores the codebase, asks clarifying questions, and presents a strategy for your approval before switching to Act Mode. |
|
||||
|
||||
Use `-a, --act` or `-p, --plan` flags to explicitly set the mode.
|
||||
|
||||
## Commands
|
||||
|
||||
### cline (default)
|
||||
|
||||
Run Cline without a subcommand to start a task or enter interactive mode.
|
||||
|
||||
```bash
|
||||
# Interactive mode (no arguments)
|
||||
cline
|
||||
|
||||
# Start a task directly
|
||||
cline "your prompt here"
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --act` | Start in Act mode (default). Cline executes actions directly. |
|
||||
| `-p, --plan` | Start in Plan mode. Cline analyzes and creates a strategy before acting. |
|
||||
| `-y, --yolo` | YOLO mode: auto-approve all actions, use plain text output, exit when complete. Ideal for CI/CD. |
|
||||
| `-m, --model <id>` | Use a specific model (e.g., `claude-sonnet-4-5-20250929`, `gpt-4o`). |
|
||||
| `-i, --images <paths...>` | Include image files with the prompt. |
|
||||
| `--thinking` | Enable extended thinking with a 1024 token budget. |
|
||||
| `--json` | Output messages as JSON (one object per line). Forces plain text mode. |
|
||||
| `--timeout <seconds>` | Maximum execution time before the task is stopped. |
|
||||
|
||||
**Mode Behavior:**
|
||||
|
||||
| Invocation | Output Mode | Why |
|
||||
|------------|-------------|-----|
|
||||
| `cline` | Interactive UI | No arguments, TTY connected |
|
||||
| `cline "prompt"` | Interactive UI | TTY connected |
|
||||
| `cline -y "prompt"` | Plain text | YOLO flag forces plain text |
|
||||
| `cline --json "prompt"` | JSON | JSON flag forces plain text |
|
||||
| `cat file \| cline "prompt"` | Plain text | stdin is piped |
|
||||
| `cline "prompt" > out.txt` | Plain text | stdout is redirected |
|
||||
|
||||
---
|
||||
|
||||
### cline task (alias: t)
|
||||
|
||||
Run a task with a prompt. This is equivalent to `cline "prompt"`.
|
||||
|
||||
```bash
|
||||
cline task "Create a REST API endpoint"
|
||||
cline t "Fix the bug in utils.js"
|
||||
```
|
||||
|
||||
**Options:** Same as the default command above.
|
||||
|
||||
---
|
||||
|
||||
### cline auth
|
||||
|
||||
Configure authentication with an AI provider.
|
||||
|
||||
```bash
|
||||
# Interactive wizard
|
||||
cline auth
|
||||
|
||||
# Quick setup with flags
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx -m claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-p, --provider <id>` | Provider ID. See [Supported Providers](#supported-providers) below. |
|
||||
| `-k, --apikey <key>` | API key for the provider. |
|
||||
| `-m, --modelid <id>` | Model ID to use (e.g., `claude-sonnet-4-5-20250929`, `gpt-4o`). |
|
||||
| `-b, --baseurl <url>` | Base URL for OpenAI-compatible providers. |
|
||||
|
||||
**Supported Providers:**
|
||||
|
||||
| Provider ID | Description |
|
||||
|-------------|-------------|
|
||||
| `anthropic` | Anthropic Claude (direct API) |
|
||||
| `openai-native` | OpenAI GPT models |
|
||||
| `openai-codex` | ChatGPT subscription via OAuth |
|
||||
| `openrouter` | OpenRouter (access multiple providers) |
|
||||
| `bedrock` | AWS Bedrock |
|
||||
| `gemini` | Google Gemini |
|
||||
| `xai` | X AI (Grok) |
|
||||
| `cerebras` | Cerebras (fast inference) |
|
||||
| `deepseek` | DeepSeek |
|
||||
| `ollama` | Ollama (local models) |
|
||||
| `lmstudio` | LM Studio (local models) |
|
||||
| `openai` | OpenAI-compatible API (custom base URL) |
|
||||
|
||||
---
|
||||
|
||||
### cline history (alias: h)
|
||||
|
||||
Browse task history with pagination.
|
||||
|
||||
```bash
|
||||
# Show recent tasks (default: 10)
|
||||
cline history
|
||||
|
||||
# Show more tasks
|
||||
cline history -n 20
|
||||
|
||||
# Paginate through history
|
||||
cline history -n 10 -p 2
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
|
||||
| `-p, --page <number>` | Page number, 1-based (default: 1) |
|
||||
|
||||
---
|
||||
|
||||
### cline config
|
||||
|
||||
View and manage configuration settings.
|
||||
|
||||
```bash
|
||||
cline config
|
||||
```
|
||||
|
||||
Opens an interactive configuration view with tabs for:
|
||||
- **Settings** - Global and workspace-specific settings
|
||||
- **Rules** - `.clinerules` files and imported rules
|
||||
- **Workflows** - Available workflows (appear as slash commands)
|
||||
- **Hooks** - Configured hook scripts
|
||||
- **Skills** - Enabled skills
|
||||
|
||||
---
|
||||
|
||||
### cline update
|
||||
|
||||
Check for updates and install the latest version.
|
||||
|
||||
```bash
|
||||
cline update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cline version
|
||||
|
||||
Show the installed CLI version.
|
||||
|
||||
```bash
|
||||
cline version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cline dev
|
||||
|
||||
Developer tools for debugging.
|
||||
|
||||
```bash
|
||||
# Open the log file
|
||||
cline dev log
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### CLINE_DIR
|
||||
|
||||
Override the default configuration directory:
|
||||
|
||||
```bash
|
||||
export CLINE_DIR=/path/to/custom/config
|
||||
cline "your task"
|
||||
```
|
||||
|
||||
When set, all Cline data (settings, secrets, task history) is stored in this directory instead of `~/.cline/data/`.
|
||||
|
||||
**Use cases:**
|
||||
- Running isolated Cline instances with different settings
|
||||
- CI/CD environments with custom state directories
|
||||
- Testing configuration changes without affecting your main setup
|
||||
|
||||
### CLINE_COMMAND_PERMISSIONS
|
||||
|
||||
Restrict which shell commands Cline can execute:
|
||||
|
||||
```bash
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"], "deny": ["rm -rf *"]}'
|
||||
```
|
||||
|
||||
**Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"allow": ["pattern1", "pattern2"],
|
||||
"deny": ["pattern3"],
|
||||
"allowRedirects": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `allow` | `string[]` | Glob patterns for allowed commands. If set, **only** matching commands are permitted. |
|
||||
| `deny` | `string[]` | Glob patterns for denied commands. Deny rules **always take precedence** over allow rules. |
|
||||
| `allowRedirects` | `boolean` | Whether to allow shell redirects (`>`, `>>`, `<`). Default: `false`. |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands (deny everything else)
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow dev commands but explicitly deny dangerous ones
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file reading with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
**How commands are evaluated:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects are detected and `allowRedirects` is not true, command is denied
|
||||
4. Each segment is validated against deny rules first, then allow rules
|
||||
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
|
||||
6. All segments must pass for the command to be allowed
|
||||
|
||||
## JSON Output Format
|
||||
|
||||
When using `--json`, each message is output as a JSON object (one per line):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "say",
|
||||
"text": "Cline is about to run a command.",
|
||||
"text": "I'll create the file now.",
|
||||
"ts": 1760501486669,
|
||||
"say": "command",
|
||||
"partial": false
|
||||
"say": "text"
|
||||
}
|
||||
```
|
||||
|
||||
### Shell Completion
|
||||
**Required fields:**
|
||||
|
||||
Generate autocompletion scripts for various shells:
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `"ask"` \| `"say"` | Message category |
|
||||
| `text` | `string` | Human-readable message content |
|
||||
| `ts` | `number` | Unix timestamp in milliseconds |
|
||||
|
||||
#### Bash
|
||||
**Optional fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `say` | `string` | Subtype when `type` is `"say"` (e.g., `"text"`, `"tool"`) |
|
||||
| `ask` | `string` | Subtype when `type` is `"ask"` (e.g., `"tool"`, `"followup"`) |
|
||||
| `reasoning` | `string` | Model reasoning (omitted when empty) |
|
||||
| `partial` | `boolean` | `true` while streaming (omitted when complete) |
|
||||
| `images` | `string[]` | Image URIs (omitted when empty) |
|
||||
| `files` | `string[]` | File paths (omitted when empty) |
|
||||
|
||||
## Configuration Files
|
||||
|
||||
Cline stores all data in `~/.cline/` by default:
|
||||
|
||||
```text
|
||||
~/.cline/
|
||||
├── data/ # Configuration directory
|
||||
│ ├── globalState.json # Global settings
|
||||
│ ├── secrets.json # API keys (stored securely)
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and conversations
|
||||
└── log/ # Debug logs (view with cline dev log)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Interactive Development
|
||||
|
||||
```bash
|
||||
# Generate bash completion
|
||||
cline completion bash > /etc/bash_completion.d/cline
|
||||
# Start interactive mode
|
||||
cline
|
||||
|
||||
# Or for user-level installation
|
||||
cline completion bash > ~/.local/share/bash-completion/completions/cline
|
||||
# Start with a task and use interactive UI
|
||||
cline "Help me refactor this codebase"
|
||||
```
|
||||
|
||||
#### Zsh
|
||||
### Direct Task Execution
|
||||
|
||||
```bash
|
||||
# Generate zsh completion
|
||||
cline completion zsh > "${fpath[1]}/_cline"
|
||||
# Run a task directly
|
||||
cline "Add error handling to utils.js"
|
||||
|
||||
# Or add to your .zshrc
|
||||
echo 'source <(cline completion zsh)' >> ~/.zshrc
|
||||
# Start in Plan mode to review strategy first
|
||||
cline -p "Design a caching layer for the API"
|
||||
|
||||
# Use a specific model
|
||||
cline -m gpt-4o "Explain this code"
|
||||
```
|
||||
|
||||
#### Fish
|
||||
### Piped Input
|
||||
|
||||
```bash
|
||||
# Generate fish completion
|
||||
cline completion fish > ~/.config/fish/completions/cline.fish
|
||||
# Pipe file contents
|
||||
cat README.md | cline "Summarize this document"
|
||||
|
||||
# Review git changes
|
||||
git diff | cline "Review these changes"
|
||||
|
||||
# Analyze test output
|
||||
npm test 2>&1 | cline "Fix any failing tests"
|
||||
```
|
||||
|
||||
#### PowerShell
|
||||
|
||||
```powershell
|
||||
# Generate PowerShell completion
|
||||
cline completion powershell > cline.ps1
|
||||
|
||||
# Add to your PowerShell profile
|
||||
Add-Content $PROFILE "cline completion powershell | Out-String | Invoke-Expression"
|
||||
```
|
||||
|
||||
### Version Command
|
||||
### Automation and CI/CD
|
||||
|
||||
```bash
|
||||
# Show version information
|
||||
cline version
|
||||
# YOLO mode for automated workflows
|
||||
cline -y "Run tests and fix failures"
|
||||
|
||||
# JSON output for scripting
|
||||
cline --json "List all TODO comments" | jq '.text'
|
||||
|
||||
# With timeout
|
||||
cline -y --timeout 600 "Run the full test suite"
|
||||
|
||||
# Chain commands
|
||||
git diff | cline -y "explain" | cline -y "write a commit message"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### CLINE_DIR
|
||||
|
||||
Override the default Cline directory location:
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Override default Cline directory
|
||||
export CLINE_DIR=/custom/path
|
||||
# Interactive wizard
|
||||
cline auth
|
||||
|
||||
# Default: ~/.cline
|
||||
# Quick setup: Anthropic
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx -m claude-sonnet-4-5-20250929
|
||||
|
||||
# Quick setup: OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# Quick setup: OpenRouter
|
||||
cline auth -p openrouter -k sk-or-xxxxx
|
||||
|
||||
# OpenAI-compatible with custom URL
|
||||
cline auth -p openai -k your-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
This directory is used for:
|
||||
- Instance registry database
|
||||
- Configuration files
|
||||
- Task history
|
||||
- Checkpoints
|
||||
## Support
|
||||
|
||||
- **Report bugs:** https://github.com/cline/cline/issues
|
||||
- **Discord community:** https://discord.gg/cline
|
||||
- **Documentation:** https://docs.cline.bot
|
||||
|
||||
## See Also
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Installation & Setup" icon="download" href="/cline-cli/installation">
|
||||
Install Cline CLI and configure authentication.
|
||||
</Card>
|
||||
|
||||
<Card title="Interactive Mode" icon="terminal" href="/cline-cli/interactive-mode">
|
||||
Keyboard shortcuts, slash commands, and file mentions.
|
||||
</Card>
|
||||
|
||||
<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="Configuration" icon="gear" href="/cline-cli/configuration">
|
||||
Environment variables and advanced settings.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: "Configuration"
|
||||
description: "Manage Cline CLI settings with cline config, environment variables, and configuration files"
|
||||
---
|
||||
|
||||
Cline CLI provides multiple ways to configure settings, from the interactive `cline config` command to environment variables for automation.
|
||||
|
||||
## The Config Command
|
||||
|
||||
Launch the configuration interface:
|
||||
|
||||
```bash
|
||||
cline config
|
||||
```
|
||||
|
||||
This opens an interactive view with tabs for different configuration categories.
|
||||
|
||||
## Configuration Tabs
|
||||
|
||||
Navigate between tabs using arrow keys.
|
||||
|
||||
### Settings Tab
|
||||
|
||||
View and edit global and workspace-specific settings:
|
||||
|
||||
- **Global State**: Settings that apply across all workspaces
|
||||
- **Workspace State**: Settings specific to the current directory
|
||||
|
||||
### Rules Tab
|
||||
|
||||
Manage Cline rules that guide AI behavior:
|
||||
|
||||
- **`.clinerules` files**: Project-specific rules in your workspace
|
||||
- **Cursor rules**: Import rules from Cursor editor format
|
||||
- **Windsurf rules**: Import rules from Windsurf editor format
|
||||
|
||||
Rules help Cline understand your project's conventions, coding standards, and preferences.
|
||||
|
||||
### Workflows Tab
|
||||
|
||||
View and manage [workflows](/customization/workflows):
|
||||
|
||||
- List available workflows
|
||||
- View workflow definitions
|
||||
- Workflows appear as slash commands in interactive mode
|
||||
|
||||
### Hooks Tab
|
||||
|
||||
Configure [hooks](/customization/hooks) for custom logic integration:
|
||||
|
||||
- Enable/disable hooks globally
|
||||
- View configured hook scripts
|
||||
- Hooks run at key points in Cline's workflow
|
||||
|
||||
<Note>
|
||||
Hooks must be enabled via settings. Use `cline config` to toggle `hooks-enabled`.
|
||||
</Note>
|
||||
|
||||
### Skills Tab
|
||||
|
||||
Manage [skills](/customization/skills) that extend Cline's capabilities:
|
||||
|
||||
- View available skills
|
||||
- Enable/disable specific skills
|
||||
- Skills provide specialized instructions for specific tasks
|
||||
|
||||
## Configuration Directory
|
||||
|
||||
Cline stores configuration in `~/.cline/data/`:
|
||||
|
||||
```text
|
||||
~/.cline/
|
||||
├── data/ # Configuration directory
|
||||
│ ├── globalState.json # Global settings
|
||||
│ ├── secrets.json # API keys (encrypted)
|
||||
│ ├── settings/ # Settings files
|
||||
│ │ └── cline_mcp_settings.json # MCP server configuration
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and data
|
||||
└── log/ # Log files
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
For debugging, view the log file:
|
||||
|
||||
```bash
|
||||
cline dev log
|
||||
```
|
||||
|
||||
This opens the log file in your default editor.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### CLINE_DIR
|
||||
|
||||
Override the default configuration directory:
|
||||
|
||||
```bash
|
||||
export CLINE_DIR=/custom/path/to/cline
|
||||
cline "your task"
|
||||
```
|
||||
|
||||
When set, all Cline data is stored in this directory instead of `~/.cline/data/`.
|
||||
|
||||
**Use cases:**
|
||||
- Running multiple isolated Cline configurations
|
||||
- Team-shared configurations
|
||||
- CI/CD with custom state directories
|
||||
|
||||
### CLINE_COMMAND_PERMISSIONS
|
||||
|
||||
Restrict which shell commands Cline can execute:
|
||||
|
||||
```bash
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"], "deny": ["rm -rf *"]}'
|
||||
```
|
||||
|
||||
**Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"allow": ["pattern1", "pattern2"],
|
||||
"deny": ["pattern3"],
|
||||
"allowRedirects": true
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `allow` | `string[]` | Glob patterns for allowed commands. If set, only matching commands are permitted. |
|
||||
| `deny` | `string[]` | Glob patterns for denied commands. Deny rules take precedence over allow. |
|
||||
| `allowRedirects` | `boolean` | Whether to allow shell redirects (`>`, `>>`, `<`). Default: `false` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow dev commands but deny dangerous ones
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
<Warning>
|
||||
When `allow` is set, all commands not matching the allow patterns are denied. Use this for security-sensitive environments.
|
||||
</Warning>
|
||||
|
||||
## Using --config Flag
|
||||
|
||||
Run Cline with a custom configuration directory:
|
||||
|
||||
```bash
|
||||
cline --config /path/to/custom/config "your task"
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
- Running isolated Cline instances
|
||||
- Testing different configurations
|
||||
- Separating work and personal setups
|
||||
|
||||
**Example: Multiple configurations**
|
||||
|
||||
```bash
|
||||
# Work configuration
|
||||
cline --config ~/.cline-work "review this PR"
|
||||
|
||||
# Personal projects
|
||||
cline --config ~/.cline-personal "help me with this side project"
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, giving you access to external tools and data sources directly from the terminal. The CLI uses the same MCP configuration format as the VS Code extension.
|
||||
|
||||
### Setting Up MCP Servers
|
||||
|
||||
To configure MCP servers for the CLI, create or edit the settings file at:
|
||||
|
||||
```
|
||||
~/.cline/data/settings/cline_mcp_settings.json
|
||||
```
|
||||
|
||||
The file uses the same JSON format as the VS Code extension:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/server.js"],
|
||||
"env": {
|
||||
"API_KEY": "your_api_key"
|
||||
},
|
||||
"alwaysAllow": ["tool1", "tool2"],
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
|
||||
|
||||
<Note>
|
||||
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
|
||||
</Note>
|
||||
|
||||
### Custom Config Directory
|
||||
|
||||
If you use the `CLINE_DIR` environment variable or `--config` flag, the MCP settings file will be located at `<your-config-dir>/data/settings/cline_mcp_settings.json` instead.
|
||||
|
||||
## Configuration for Local Providers
|
||||
|
||||
### Ollama
|
||||
|
||||
Configure context window size for Ollama:
|
||||
|
||||
```bash
|
||||
# In settings or via config
|
||||
cline config
|
||||
# Navigate to Settings tab, find ollama-api-options-ctx-num
|
||||
```
|
||||
|
||||
Or set via environment:
|
||||
|
||||
```bash
|
||||
# Set context window to 32K tokens
|
||||
cline -m ollama/llama3 "your task"
|
||||
```
|
||||
|
||||
### LM Studio
|
||||
|
||||
Configure max tokens for LM Studio:
|
||||
|
||||
```bash
|
||||
cline config
|
||||
# Navigate to Settings tab, find lm-studio-max-tokens
|
||||
```
|
||||
|
||||
## Importing Configuration
|
||||
|
||||
### From VS Code Extension
|
||||
|
||||
If you use the Cline VS Code extension, the CLI automatically detects and can share some settings. However, the CLI maintains its own configuration for terminal-specific features.
|
||||
|
||||
### From Other CLI Tools
|
||||
|
||||
See [Installation & Setup](/cline-cli/installation#option-3-import-from-existing-tools) for importing configurations from:
|
||||
- Codex CLI
|
||||
- OpenCode
|
||||
|
||||
## Configuration Best Practices
|
||||
|
||||
### For Development
|
||||
|
||||
Use the default configuration with workspace-specific rules:
|
||||
|
||||
```bash
|
||||
# Add project-specific rules
|
||||
echo "Use TypeScript strict mode" > .clinerules/typescript.md
|
||||
```
|
||||
|
||||
### For CI/CD
|
||||
|
||||
Use environment variables and `--yolo` mode:
|
||||
|
||||
```bash
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm test", "npm run build"]}'
|
||||
cline -y "run tests and fix any failures"
|
||||
```
|
||||
|
||||
### For Teams
|
||||
|
||||
Share configuration via version control:
|
||||
|
||||
```bash
|
||||
# Commit .clinerules/ to your repo
|
||||
git add .clinerules/
|
||||
git commit -m "Add Cline rules for team"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Configuration Not Persisting
|
||||
|
||||
1. Check write permissions on `~/.cline/data/`
|
||||
2. Ensure `CLINE_DIR` isn't set to a read-only location
|
||||
3. Verify the config directory exists
|
||||
|
||||
### Environment Variables Not Working
|
||||
|
||||
1. Ensure variables are exported: `export CLINE_DIR=/path`
|
||||
2. Check for typos in variable names
|
||||
3. Verify JSON syntax for `CLINE_COMMAND_PERMISSIONS`
|
||||
|
||||
### Reset Configuration
|
||||
|
||||
To start fresh, remove the configuration directory:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.cline/data/
|
||||
cline auth # Re-authenticate
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<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="CLI Reference" icon="terminal" href="/cline-cli/cli-reference">
|
||||
Complete command documentation with all flags and options.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -0,0 +1,457 @@
|
||||
---
|
||||
title: "Getting Started"
|
||||
description: "Run Cline AI coding agents directly in your terminal with an interactive CLI or automated workflows"
|
||||
---
|
||||
|
||||
## What is Cline CLI?
|
||||
|
||||
Cline CLI brings the full power of Cline to your terminal. Whether you prefer an interactive experience or automated workflows for CI/CD pipelines, the CLI adapts to your needs.
|
||||
|
||||
The CLI supports macOS, Linux, and Windows, and works with all the same AI providers as the VS Code extension.
|
||||
|
||||
## Two Ways to Use Cline CLI
|
||||
|
||||
The CLI operates in two distinct modes, automatically selecting the appropriate one based on how you invoke it:
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
Interactive mode is designed for **hands-on development sessions** where you want to collaborate with Cline in real-time. It provides a rich terminal interface that feels like chatting with an AI assistant.
|
||||
|
||||
**When it activates:** Running `cline` without arguments, or when stdin is a TTY (terminal).
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- **Real-time conversation** - Type messages, see Cline's responses, and iterate on tasks
|
||||
- **Visual feedback** - Animated welcome screen, syntax-highlighted code, and progress indicators
|
||||
- **File mentions** with `@` - Reference workspace files with fuzzy search autocomplete
|
||||
- **Slash commands** with `/` - Quick access to `/settings`, `/history`, `/models`, and workflows
|
||||
- **Keyboard shortcuts** - `Tab` to toggle Plan/Act, `Shift+Tab` for auto-approve all
|
||||
- **Session summaries** - See tasks completed, files modified, and token usage on exit
|
||||
- **Settings panel** - Configure providers, models, and features without leaving the CLI
|
||||
|
||||
Interactive mode keeps you in control. You review Cline's plan, approve or modify actions, and guide the conversation.
|
||||
|
||||
[Learn more about interactive mode →](/cline-cli/interactive-mode)
|
||||
|
||||
### Headless Mode (Non-Interactive)
|
||||
|
||||
Headless mode is designed for **automation, scripting, and CI/CD pipelines** where human interaction isn't possible or desired.
|
||||
|
||||
**When it activates:** Using the `-y`/`--yolo` flag, `--json` flag, piping input/output, or when stdin is not a TTY.
|
||||
|
||||
```bash
|
||||
# Headless with auto-approval (YOLO mode)
|
||||
cline -y "Run tests and fix any failures"
|
||||
|
||||
# Headless with JSON output for parsing
|
||||
cline --json "List all TODO comments" | jq '.text'
|
||||
|
||||
# Headless via piped input
|
||||
cat README.md | cline "Summarize this document"
|
||||
|
||||
# Chain multiple headless commands
|
||||
git diff | cline -y "explain these changes" | cline -y "write a commit message"
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- **No visual interface** - Clean text or JSON output suitable for scripting
|
||||
- **Automatic execution** - With `-y`, Cline approves all actions and runs autonomously
|
||||
- **Process control** - Exits automatically when the task completes
|
||||
- **Piped workflows** - Read from stdin, write to stdout, chain with other commands
|
||||
- **Machine-readable output** - Use `--json` to get structured output for parsing
|
||||
|
||||
<Warning>
|
||||
Headless mode with `-y` gives Cline full autonomy. Run on a clean git branch so you can easily revert changes if needed.
|
||||
</Warning>
|
||||
|
||||
### Mode Detection Summary
|
||||
|
||||
Cline automatically detects which mode to use based on your invocation. This table shows how different command patterns trigger each mode, helping you predict behavior in scripts and interactive sessions.
|
||||
|
||||
| Invocation | Mode | Reason |
|
||||
|------------|------|--------|
|
||||
| `cline` | Interactive | No arguments, TTY connected |
|
||||
| `cline "task"` | Interactive | TTY connected |
|
||||
| `cline -y "task"` | Headless | YOLO flag forces headless |
|
||||
| `cline --json "task"` | Headless | JSON flag forces headless |
|
||||
| `cat file \| cline "task"` | Headless | stdin is piped |
|
||||
| `cline "task" > output.txt` | Headless | stdout is redirected |
|
||||
|
||||
[Learn more about headless mode →](/cline-cli/three-core-flows)
|
||||
|
||||
## Supported Model Providers
|
||||
|
||||
Cline CLI supports all providers available in the VS Code extension:
|
||||
|
||||
- **Anthropic** (Claude)
|
||||
- **OpenAI** (GPT-4o, GPT-4)
|
||||
- **OpenAI Codex** (ChatGPT subscription)
|
||||
- **OpenRouter**
|
||||
- **AWS Bedrock**
|
||||
- **Google Gemini**
|
||||
- **X AI (Grok)**
|
||||
- **Cerebras**
|
||||
- **DeepSeek**
|
||||
- **Ollama** (local models)
|
||||
- **LM Studio** (local models)
|
||||
- **OpenAI Compatible** (any compatible API)
|
||||
|
||||
During setup, authenticate with `cline auth` to configure your preferred provider. [See authentication →](#authenticate)
|
||||
|
||||
## What You Can Build
|
||||
|
||||
### Automated Code Maintenance
|
||||
|
||||
Keep your codebase healthy with automated fixes. Cline scans for issues and applies corrections across multiple files.
|
||||
|
||||
```bash
|
||||
cline -y "Fix all ESLint errors in src/"
|
||||
```
|
||||
Finds and fixes linting violations throughout your source directory.
|
||||
|
||||
```bash
|
||||
cline -y "Update all deprecated React lifecycle methods"
|
||||
```
|
||||
Migrates legacy code patterns to modern equivalents (e.g., `componentWillMount` → `useEffect`).
|
||||
|
||||
```bash
|
||||
cline -y "Update dependencies with known vulnerabilities"
|
||||
```
|
||||
Identifies outdated packages with security issues and updates them to safe versions.
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Integrate Cline into your continuous integration pipelines for automated code review and documentation.
|
||||
|
||||
```bash
|
||||
git diff origin/main | cline -y "Review these changes for issues"
|
||||
```
|
||||
Pipes your PR diff to Cline for automated code review, catching bugs and style issues before merge.
|
||||
|
||||
```bash
|
||||
git log --oneline v1.0..v1.1 | cline -y "Write release notes"
|
||||
```
|
||||
Generates human-readable release notes from your commit history between two tags.
|
||||
|
||||
```bash
|
||||
cline -y "Run tests and fix failures" --timeout 600
|
||||
```
|
||||
Executes your test suite, analyzes failures, and attempts fixes with a 10-minute timeout.
|
||||
|
||||
### Development Workflows
|
||||
|
||||
From quick edits to complex refactors, Cline adapts to your workflow.
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
Launches interactive mode for exploratory development and back-and-forth collaboration.
|
||||
|
||||
```bash
|
||||
cline "Refactor this function to use async/await"
|
||||
```
|
||||
Executes a focused task directly from the command line with approval prompts at key steps.
|
||||
|
||||
```bash
|
||||
cline "Based on @src/api.ts, add error handling to all endpoints"
|
||||
```
|
||||
Uses file mentions (`@`) to give Cline context about specific files in your workspace.
|
||||
|
||||
### Custom Shell Pipelines
|
||||
|
||||
Chain Cline with other CLI tools to build powerful automation workflows.
|
||||
|
||||
```bash
|
||||
gh pr diff 123 | cline -y "Review this PR"
|
||||
```
|
||||
Fetches a GitHub PR diff and pipes it directly to Cline for review.
|
||||
|
||||
```bash
|
||||
cline --json "List all TODO comments" | jq '.text'
|
||||
```
|
||||
Outputs structured JSON that you can process with tools like `jq` for scripting.
|
||||
|
||||
```bash
|
||||
git diff | cline -y "explain" | cline -y "write a haiku about these changes"
|
||||
```
|
||||
Chains multiple Cline invocations together for creative multi-step workflows.
|
||||
|
||||
## Features at a Glance
|
||||
|
||||
| Feature | Interactive Mode | Non-Interactive Mode |
|
||||
|---------|------------------|----------------------|
|
||||
| Interactive chat | ✓ | - |
|
||||
| File mentions (@) | ✓ | ✓ (inline) |
|
||||
| Slash commands (/) | ✓ | - |
|
||||
| Settings panel | ✓ | `cline config` |
|
||||
| Plan/Act toggle | ✓ (Tab) | `-p` / `-a` flags |
|
||||
| Auto-approve | ✓ (Shift+Tab) | `-y` flag |
|
||||
| Session summary | ✓ | - |
|
||||
| JSON output | - | `--json` |
|
||||
| Piped input | - | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
In just a few minutes, you can install the CLI, authenticate with your preferred AI provider, and start running tasks from any directory on your machine.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Cline CLI requires **Node.js version 20 or higher**. We recommend Node.js 22 for the best experience.
|
||||
|
||||
Check your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
If you need to install or update Node.js, visit [nodejs.org](https://nodejs.org) or use a version manager like [nvm](https://github.com/nvm-sh/nvm).
|
||||
|
||||
### Install Cline CLI
|
||||
|
||||
Install globally via npm:
|
||||
|
||||
```bash
|
||||
npm install -g cline
|
||||
```
|
||||
|
||||
Verify the installation:
|
||||
|
||||
```bash
|
||||
cline version
|
||||
```
|
||||
|
||||
<Tip>
|
||||
To install a specific version, use `npm install -g cline@2.0.0`. Check [npm](https://www.npmjs.com/package/cline) for available versions.
|
||||
</Tip>
|
||||
|
||||
### Authenticate
|
||||
|
||||
After installation, run the authentication wizard:
|
||||
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
This launches an interactive wizard with multiple options. Choose the method that works best for your workflow.
|
||||
|
||||
#### Option 1: Sign in with Cline (Recommended)
|
||||
|
||||
Select **"Sign in with Cline"** to authenticate with your Cline account via OAuth. Your browser opens automatically to complete sign-in.
|
||||
|
||||
#### Option 2: Sign in with ChatGPT Subscription
|
||||
|
||||
If you have a ChatGPT Plus or Pro subscription, select **"Sign in with ChatGPT Subscription"**. This uses OpenAI's Codex OAuth to authenticate with your existing subscription.
|
||||
|
||||
#### Option 3: Import from Existing Tools
|
||||
|
||||
Already using another AI coding CLI? Cline can import your existing configuration:
|
||||
|
||||
- **Import from Codex CLI** - Imports credentials from `~/.codex/auth.json`
|
||||
- **Import from OpenCode** - Imports configuration from `~/.local/share/opencode/auth.json`
|
||||
|
||||
#### Option 4: Bring Your Own API Key
|
||||
|
||||
Select **"Bring your own API key"** to manually configure any supported provider. Or skip the wizard entirely with flags:
|
||||
|
||||
```bash
|
||||
# Anthropic (Claude)
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx -m claude-sonnet-4-5-20250929
|
||||
|
||||
# OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenRouter
|
||||
cline auth -p openrouter -k sk-or-xxxxx -m anthropic/claude-sonnet-4-5-20250929
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
**Quick Setup Flags:**
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-p, --provider <id>` | Provider ID (e.g., `anthropic`, `openai-native`, `openrouter`) |
|
||||
| `-k, --apikey <key>` | Your API key |
|
||||
| `-m, --modelid <id>` | Model ID (e.g., `claude-sonnet-4-5-20250929`, `gpt-4o`) |
|
||||
| `-b, --baseurl <url>` | Base URL for OpenAI-compatible providers |
|
||||
|
||||
<Tip>
|
||||
Flags are especially useful for scripting, CI/CD environments, or setting up multiple machines.
|
||||
</Tip>
|
||||
|
||||
#### Supported Providers
|
||||
|
||||
| Provider | Provider ID | Notes |
|
||||
|----------|-------------|-------|
|
||||
| Anthropic | `anthropic` | Direct Claude API access |
|
||||
| OpenAI | `openai-native` | GPT-4o, GPT-4, etc. |
|
||||
| OpenAI Codex | `openai-codex` | ChatGPT subscription OAuth |
|
||||
| OpenRouter | `openrouter` | Access multiple providers |
|
||||
| AWS Bedrock | `bedrock` | Claude via AWS |
|
||||
| Google Gemini | `gemini` | Gemini Pro, etc. |
|
||||
| X AI (Grok) | `xai` | Grok models |
|
||||
| Cerebras | `cerebras` | Fast inference |
|
||||
| DeepSeek | `deepseek` | DeepSeek models |
|
||||
| Ollama | `ollama` | Local models |
|
||||
| LM Studio | `lmstudio` | Local models |
|
||||
| OpenAI Compatible | `openai` | Any OpenAI-compatible API |
|
||||
|
||||
### Verify Your Setup
|
||||
|
||||
Confirm everything is working with a simple test:
|
||||
|
||||
```bash
|
||||
cline "What is 2 + 2?"
|
||||
```
|
||||
|
||||
If Cline responds with an answer, your installation and authentication are complete.
|
||||
|
||||
Check your current configuration:
|
||||
|
||||
```bash
|
||||
cline config
|
||||
```
|
||||
|
||||
### Quick Start
|
||||
|
||||
Now you're ready to use Cline. Choose how you want to work:
|
||||
|
||||
#### Interactive Mode
|
||||
|
||||
Launch the interactive CLI for development:
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
|
||||
You'll see the Cline welcome screen. Type your task and press Enter. Use:
|
||||
- `Tab` to toggle between Plan and Act modes
|
||||
- `Shift+Tab` to enable auto-approve
|
||||
- `/help` for available commands
|
||||
|
||||
[Learn more about interactive mode →](/cline-cli/interactive-mode)
|
||||
|
||||
#### Direct Task Execution
|
||||
|
||||
Run a task directly from your shell:
|
||||
|
||||
```bash
|
||||
cline "Add error handling to utils.js"
|
||||
```
|
||||
|
||||
For non-interactive execution (perfect for scripts and CI/CD):
|
||||
|
||||
```bash
|
||||
cline -y "Run tests and fix any failures"
|
||||
```
|
||||
|
||||
[Learn more about headless mode →](/cline-cli/three-core-flows)
|
||||
|
||||
### Switching Providers
|
||||
|
||||
To change your configured provider at any time:
|
||||
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
You can also use the settings panel in interactive mode:
|
||||
|
||||
```bash
|
||||
cline
|
||||
# Then type: /settings
|
||||
# Navigate to the API tab
|
||||
```
|
||||
|
||||
### Updating
|
||||
|
||||
Check for updates and install the latest version:
|
||||
|
||||
```bash
|
||||
cline update
|
||||
```
|
||||
|
||||
Or update manually via npm:
|
||||
|
||||
```bash
|
||||
npm update -g cline
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Command Not Found
|
||||
|
||||
If `cline` is not found after installation:
|
||||
|
||||
1. Ensure npm global bin is in your PATH:
|
||||
```bash
|
||||
npm bin -g
|
||||
```
|
||||
|
||||
2. Add the path to your shell configuration (`.bashrc`, `.zshrc`, etc.):
|
||||
```bash
|
||||
export PATH="$PATH:$(npm bin -g)"
|
||||
```
|
||||
|
||||
3. Restart your terminal or source your shell config.
|
||||
|
||||
#### Permission Errors
|
||||
|
||||
If you get permission errors during installation:
|
||||
|
||||
```bash
|
||||
# Option 1: Use a Node version manager (recommended)
|
||||
# nvm, fnm, or volta handle permissions automatically
|
||||
|
||||
# Option 2: Fix npm permissions
|
||||
# See: https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally
|
||||
```
|
||||
|
||||
#### OAuth Flow Issues
|
||||
|
||||
If the browser doesn't open automatically during OAuth:
|
||||
1. Copy the URL from the terminal
|
||||
2. Paste it in your browser manually
|
||||
3. Complete the sign-in flow
|
||||
4. Return to the terminal
|
||||
|
||||
#### API Key Validation
|
||||
|
||||
If your API key is rejected:
|
||||
1. Verify the key is correct and hasn't expired
|
||||
2. Check that you've selected the correct provider
|
||||
3. Ensure your API account has the necessary permissions
|
||||
|
||||
**Provider-specific tips:**
|
||||
- **Anthropic**: Keys start with `sk-ant-`
|
||||
- **OpenAI**: Keys start with `sk-`
|
||||
- **AWS Bedrock**: Requires AWS credentials configured separately. See [AWS Bedrock documentation](/provider-config/aws-bedrock/api-key).
|
||||
|
||||
### Uninstallation
|
||||
|
||||
To remove Cline CLI:
|
||||
|
||||
```bash
|
||||
npm uninstall -g cline
|
||||
```
|
||||
|
||||
To also remove configuration data:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.cline
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Interactive Mode](/cline-cli/interactive-mode)** - Master the interactive CLI with shortcuts and slash commands
|
||||
- **[Headless Mode](/cline-cli/three-core-flows)** - Run Cline autonomously in scripts, CI/CD pipelines, and automated workflows
|
||||
- **[Configuration](/cline-cli/configuration)** - Configure settings, rules, workflows, and environment variables
|
||||
- **[CLI Reference](/cline-cli/cli-reference)** - Complete command documentation with all flags and options
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user