mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
252 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3700d77a1d | |||
| bfeb8af23f | |||
| c4b1160389 | |||
| 99f05762cf | |||
| 77ab5a89b4 | |||
| dc8702bcdc | |||
| 486e2ff0d3 | |||
| f003e0fa75 | |||
| 12b2aaa2eb | |||
| 0172870f08 | |||
| 96a2abbb39 | |||
| 163ad28f2c | |||
| 56ce831bf7 | |||
| c4c9f99e97 | |||
| 27d4eec60a | |||
| 2d39095b0d | |||
| 13f89db752 | |||
| af67978d39 | |||
| 03534ed7e5 | |||
| e41aba9354 | |||
| 739790fc9c | |||
| 6993260016 | |||
| 3b6c861ace | |||
| 35a0732cf1 | |||
| 02b44217b1 | |||
| 04fb2da9b5 | |||
| 94f75df12c | |||
| 624433a824 | |||
| 0a267bd078 | |||
| e9c5882179 | |||
| 537ca97cf1 | |||
| 581c2a0282 | |||
| f9d8262e31 | |||
| c8927f1971 | |||
| dde21a0e8c | |||
| 07d630b614 | |||
| cfe25729c0 | |||
| 4818f53ae3 | |||
| f4680b9eac | |||
| c9f928e485 | |||
| 49b8569281 | |||
| c19d76cd80 | |||
| e0e9f8a862 | |||
| 79a5052869 | |||
| a5c048f738 | |||
| f0514758a8 | |||
| 499f7bafbb | |||
| 415653d0c8 | |||
| 5fa12f389c | |||
| b1295471a0 | |||
| 303d99ff81 | |||
| 543a228fd9 | |||
| 5cdacca80c | |||
| 0b5024dc42 | |||
| 23a2c24366 | |||
| 58cfed5613 | |||
| 20bdc3cead | |||
| 525d1b6e21 | |||
| d5169f32f7 | |||
| e23b953a02 | |||
| cd8690b939 | |||
| fd961c2cbc | |||
| 1691177bee | |||
| 488adb42a2 | |||
| b59aa52c9e | |||
| 06f40a75c6 | |||
| 49eb4f95da | |||
| ec0fde8602 | |||
| bee130950e | |||
| 1fb423bc8e | |||
| 7905376e20 | |||
| 01740fbda9 | |||
| d4622a75df | |||
| dd145671af | |||
| 1bb2962b26 | |||
| 7afd207b4d | |||
| 28c7ffd216 | |||
| dbc0e8aa1d | |||
| 714236a233 | |||
| 6c152bc307 | |||
| a9f7e014f9 | |||
| f9e758a29e | |||
| 74591cb254 | |||
| 9c273551e2 | |||
| 9d40ee4529 | |||
| 2140db1aae | |||
| 6714587e6e | |||
| 30dcfe05c3 | |||
| ce1d3bc5b9 | |||
| f5fb30e98a | |||
| 67ec7bb8b2 | |||
| 2c42b46e71 | |||
| af19e0d560 | |||
| 27c7720f71 | |||
| 9b27ac8cb9 | |||
| be9fbb01bc | |||
| 82331aff2d | |||
| 7d9c69687f | |||
| 0916c78d52 | |||
| b31c3c5c1d | |||
| 484807c472 | |||
| 23490f7e6f | |||
| 8ea0543b89 | |||
| c7c9813056 | |||
| ff2bdde179 | |||
| a27e0bddbf | |||
| 32199574a9 | |||
| fccb633d08 | |||
| f5679b5c91 | |||
| ee88ddd678 | |||
| ee50091c9a | |||
| f6cc991b48 | |||
| 02b6c1fc91 | |||
| 5df13d8f32 | |||
| 918e32edee | |||
| 06d8e7ae9f | |||
| 2b35590de8 | |||
| 2788d85556 | |||
| 4ffb28377a | |||
| fa570fa500 | |||
| 23f64d24c6 | |||
| f4c79e2d21 | |||
| 10449e458e | |||
| 2ac5a0ea1f | |||
| cb34c643d7 | |||
| d9f4946665 | |||
| cb2355d881 | |||
| 04ce2878aa | |||
| c52d47decd | |||
| 26ca343dae | |||
| 81781a6143 | |||
| e2c92051f4 | |||
| f09a5d79ef | |||
| 8822846800 | |||
| c2a7836d0c | |||
| e84ff527c4 | |||
| 349d7cf24b | |||
| d5af7a2bad | |||
| 77a4bfa3f8 | |||
| d0d9ae5e9a | |||
| 0d188ade2f | |||
| d043ba9ec9 | |||
| dc35c62d5d | |||
| 2dc54861ba | |||
| 5cff5f3f24 | |||
| 39bb06bb93 | |||
| 599e236546 | |||
| 06f015c9d0 | |||
| 49bdafe10e | |||
| 955b06d4fa | |||
| 34ac2176e6 | |||
| c5e87a800b | |||
| 92c9cc5ddc | |||
| dd0b7697f2 | |||
| 9d68aa8b8e | |||
| c59fe3e62c | |||
| 7602f7b7c6 | |||
| a1debe1328 | |||
| 990a00d70f | |||
| a125a61cbd | |||
| e3e6e1ef77 | |||
| 09e897c8f2 | |||
| 6caeeb5da6 | |||
| 40550af42c | |||
| e85e4d6f0c | |||
| d26f76c6e4 | |||
| 5c6f725079 | |||
| 4a456e2145 | |||
| c029e0ffe4 | |||
| cff2217283 | |||
| 904617b573 | |||
| 20fae7e14b | |||
| 699ed190b7 | |||
| 28ae6d6ca1 | |||
| beca76fb32 | |||
| 8de9935f9a | |||
| 6bc700e3db | |||
| 2f17a1a341 | |||
| 639121b644 | |||
| 1187852bed | |||
| 7acc036d91 | |||
| 1fea5aaa63 | |||
| 8de7294de8 | |||
| 2e4ded7f14 | |||
| ac94eb4cf4 | |||
| 73635cf68f | |||
| 9eb0da6c2b | |||
| 59c10df046 | |||
| db4d9a267a | |||
| 734dc6e57f | |||
| 27befb16f5 | |||
| 1c32b08644 | |||
| 5d81bf0fe2 | |||
| f44771acd5 | |||
| 786728e0d4 | |||
| 9714cf8861 | |||
| c5db44964e | |||
| ed38739d65 | |||
| 50bc90835c | |||
| 4d41766b9f | |||
| e958e29439 | |||
| b337bb706e | |||
| 314e06731a | |||
| 3a6f8b98dd | |||
| 65f9322bd9 | |||
| 33983e80f8 | |||
| 8648154a74 | |||
| 5d91ac037e | |||
| 6179dfe0a4 | |||
| cd9e28e6d0 | |||
| 8d56a697eb | |||
| fd506b14b5 | |||
| 55d268773d | |||
| bf978c2c42 | |||
| 7e06dbbd33 | |||
| 8d4822d781 | |||
| dbcaae65d1 | |||
| 66db36ff5b | |||
| a53d17a857 | |||
| de56305315 | |||
| 5cd22e3f86 | |||
| bd9150837d | |||
| fbb494b88a | |||
| 16b4c48457 | |||
| 058c86872b | |||
| d3e8f562bc | |||
| 1594d44190 | |||
| c8419ffa99 | |||
| cf6c80504f | |||
| 230f615927 | |||
| 08e00dc3bf | |||
| c01bf73d3f | |||
| df28359114 | |||
| d3631f0485 | |||
| eb18c556f4 | |||
| b18284ea78 | |||
| e4e99ec711 | |||
| b7b4d0bfe8 | |||
| c12b00e7a9 | |||
| 73bb8c2f64 | |||
| 0e1d513462 | |||
| d6a1b338bc | |||
| 0091fb6ffb | |||
| de3e3fe34a | |||
| e136d75621 | |||
| 91127ffdb9 | |||
| 11f76f0218 | |||
| 74f1b9c75d | |||
| 942c78119e | |||
| 3130c3c96e | |||
| f0225bc20d | |||
| 1bf158245c |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add .agents/skills as default skill directory (global and local)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add endpoint configuration file support for on-premise deployments
|
||||
|
||||
Enterprise customers can now configure custom API endpoints by creating a `~/.cline/endpoints.json` file with custom URLs for `appBaseUrl`, `apiBaseUrl`, and `mcpBaseUrl`. When this file is present, Cline runs in on-premise mode with the custom endpoints.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add comprehensive LLM evaluation framework with CI integration
|
||||
|
||||
- Smoke tests: 7 curated scenarios testing tools across providers (Claude, GPT-5, Gemini)
|
||||
- Analysis framework: pass@k metrics for measuring reliability
|
||||
- CI workflow: Parallel smoke tests on PRs with ~3min execution time
|
||||
- E2E runner: cline-bench integration for real-world task evaluation (local only for now)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: prevent infinite retry loops when replace_in_file fails repeatedly
|
||||
|
||||
Add safeguards to prevent the LLM from getting stuck in infinite retry loops when `replace_in_file` operations fail repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: skip diff error UI handling during streaming to prevent flickering
|
||||
|
||||
Suppress diff view error notifications while content is actively streaming to prevent visual flickering and improve user experience. Error handling is deferred until streaming completes.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix(extract-text): strip notebook outputs to reduce context size
|
||||
|
||||
Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing the amount of context sent to the LLM while preserving the essential code and markdown content.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: throttle diff view updates during streaming
|
||||
|
||||
Add throttling to diff view updates during content streaming to reduce UI flickering and improve performance. Updates are now batched at reasonable intervals instead of firing on every token received.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Disable PostHog telemetry, error tracking, and feature flags in self-hosted mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove deprecated zai-glm-4.6 model from Cerebras provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Make Sonnet 4.5 the default Amazon Bedrock model id
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable PostHog and build-time OpenTelemetry telemetry in self-hosted/on-premise mode. Enterprise customers running self-hosted deployments will no longer send any telemetry to Cline's collectors. Runtime environment OTEL and remote config OTEL remain available for enterprises to configure their own telemetry collection.
|
||||
@@ -147,29 +147,14 @@ 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-file /tmp/pr-body.md --base main --draft
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main
|
||||
```
|
||||
|
||||
**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.
|
||||
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
|
||||
```
|
||||
|
||||
## Post-Creation
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
# CLI Development
|
||||
|
||||
The CLI lives in `cli/` and uses React Ink for terminal UI.
|
||||
The CLI lives in `cli-ts/` and uses React Ink for terminal UI.
|
||||
|
||||
- If needed, look at `cli/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
|
||||
- If needed, look at `cli-ts/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
|
||||
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
|
||||
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
|
||||
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
|
||||
@@ -11,7 +11,7 @@ The CLI lives in `cli/` and uses React Ink for terminal UI.
|
||||
|
||||
When adding a new API provider to the extension, you must also update the CLI:
|
||||
|
||||
1. **Update `cli/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
|
||||
1. **Update `cli-ts/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
|
||||
```typescript
|
||||
import { newProviderDefaultModelId, newProviderModels } from "@/shared/api"
|
||||
|
||||
@@ -21,7 +21,7 @@ When adding a new API provider to the extension, you must also update the CLI:
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli/src/utils/provider-config.ts`:
|
||||
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli-ts/src/utils/provider-config.ts`:
|
||||
```typescript
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
|
||||
@@ -147,17 +147,6 @@ 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()`.
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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)"
|
||||
'''
|
||||
@@ -16,6 +16,13 @@
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPENTELEMETRY (Optional - for advanced telemetry)
|
||||
# ============================================================================
|
||||
|
||||
+3
-2
@@ -1,2 +1,3 @@
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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.
|
||||
@@ -0,0 +1,272 @@
|
||||
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: 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 with a warm thank you 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
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,312 @@
|
||||
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: 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 with a warm thank you 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'
|
||||
@@ -80,16 +80,13 @@ jobs:
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Publish NPM Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm you want to publish to NPM'
|
||||
@@ -9,10 +9,9 @@ on:
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # Required for pushing tags
|
||||
id-token: write # Required for npm trusted publishing (OIDC)
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
contents: read
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -21,7 +20,7 @@ jobs:
|
||||
publish-npm-release:
|
||||
needs: test
|
||||
name: Publish Cline CLI to NPM
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && inputs.confirm_publish == 'publish'
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && github.event.inputs.confirm_publish == 'publish'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -31,11 +30,32 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install root dependencies and CLI dependencies
|
||||
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
uses: actions/cache@v4
|
||||
id: cli-ts-cache
|
||||
with:
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install cli-ts dependencies
|
||||
if: steps.cli-ts-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli-ts && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
run: npm run protos
|
||||
@@ -43,12 +63,15 @@ jobs:
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
# Read version from cli-ts/package.json
|
||||
VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build standalone NPM package
|
||||
- name: Clean previous builds
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Build and package CLI
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -59,6 +82,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- name: Verify build output
|
||||
@@ -66,25 +90,20 @@ jobs:
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
echo "Checking dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- 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,19 +1,14 @@
|
||||
name: Publish NPM Nightly
|
||||
|
||||
on:
|
||||
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
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -32,12 +27,6 @@ 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
|
||||
@@ -50,12 +39,34 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install root dependencies and CLI dependencies
|
||||
# Cache root dependencies
|
||||
- name: Cache root 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
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: cli-ts-cache
|
||||
with:
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install cli-ts dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.cli-ts-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli-ts && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -65,32 +76,36 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
run: |
|
||||
# Read base version from cli/package.json (e.g., "2.0.0")
|
||||
BASE_VERSION=$(node -p "require('./cli/package.json').version")
|
||||
# Read base version from cli-ts/package.json (e.g., "2.0.0")
|
||||
BASE_VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
|
||||
# Generate timestamp (Unix epoch seconds)
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Create unique nightly version: 1.0.9-nightly.1736365200
|
||||
# Create unique nightly version: 2.0.0-nightly.1736365200
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Generated nightly version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update cli/package.json with nightly version
|
||||
- name: Update cli-ts/package.json with nightly version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
# Update version with timestamp-based nightly version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
|
||||
const pkg = JSON.parse(fs.readFileSync('cli-ts/package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
fs.writeFileSync('cli-ts/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
"
|
||||
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli/package.json | grep '"version"'
|
||||
cat cli-ts/package.json | grep '"version"'
|
||||
|
||||
- name: Clean previous builds
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Build and package CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -104,6 +119,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- name: Verify build output
|
||||
@@ -112,14 +128,16 @@ jobs:
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
echo "Checking dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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 }}"
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
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
|
||||
with:
|
||||
force_publish: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
|
||||
@@ -38,10 +38,28 @@ jobs:
|
||||
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') }}
|
||||
|
||||
- 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
|
||||
|
||||
@@ -44,10 +44,28 @@ jobs:
|
||||
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') }}
|
||||
|
||||
- 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
|
||||
|
||||
+63
-25
@@ -28,17 +28,27 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
- 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') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
@@ -63,17 +73,27 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
- 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') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
@@ -115,11 +135,6 @@ 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
|
||||
@@ -141,18 +156,35 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
testing-platform/package-lock.json
|
||||
|
||||
- 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') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
@@ -162,11 +194,17 @@ jobs:
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
run: cd testing-platform && npm ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
continue-on-error: true
|
||||
timeout-minutes: 7
|
||||
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
# Temporarily wrapping the test command to always return a neutral exit code.
|
||||
# This prevents the job from showing as failed and avoids distracting developers
|
||||
# until the integration tests are ready to be enforced.
|
||||
run: |
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -38,7 +38,6 @@ coverage-unit
|
||||
src/generated/
|
||||
src/shared/proto/
|
||||
webview-ui/src/services/grpc-client.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
@@ -48,6 +47,3 @@ test-results
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Smoke test results (generated)
|
||||
evals/smoke-tests/results/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "evals/cline-bench"]
|
||||
path = evals/cline-bench
|
||||
url = https://github.com/cline/cline-bench.git
|
||||
+2
-117
@@ -1,129 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
- Add new model: Arcee Trinity Large Preview
|
||||
- Add new model: Moonshot Kimi K2.5
|
||||
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
|
||||
## [3.54.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Native tool calls support for Ollama provider
|
||||
- Sonnet 4.5 is now the default Amazon Bedrock model id
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
|
||||
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
|
||||
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Mistral's Devstral-2512 free from the free models list
|
||||
- Removed deprecated zai-glm-4.6 model from Cerebras provider
|
||||
|
||||
## [3.53.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug in responses API
|
||||
- Bug in responses API
|
||||
|
||||
## [3.53.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed grok model from free tier
|
||||
- Removed grok model from free tier
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Cline Bot Inc.
|
||||
Copyright 2025 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 © 2026 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.317 4.15557C18.7873 3.45369 17.147 2.93658 15.4319 2.6404C15.4007 2.63469 15.3695 2.64897 15.3534 2.67754C15.1424 3.05276 14.9087 3.54225 14.7451 3.927C12.9004 3.65083 11.0652 3.65083 9.25832 3.927C9.09465 3.5337 8.85248 3.05276 8.64057 2.67754C8.62449 2.64992 8.59328 2.63564 8.56205 2.6404C6.84791 2.93563 5.20756 3.45275 3.67693 4.15557C3.66368 4.16129 3.65233 4.17082 3.64479 4.18319C0.533392 8.83155 -0.31895 13.3657 0.0991801 17.8436C0.101072 17.8655 0.11337 17.8864 0.130398 17.8997C2.18321 19.4073 4.17171 20.3225 6.12328 20.9291C6.15451 20.9386 6.18761 20.9272 6.20748 20.9015C6.66913 20.2711 7.08064 19.6063 7.43348 18.9073C7.4543 18.8664 7.43442 18.8178 7.39186 18.8016C6.73913 18.554 6.1176 18.2521 5.51973 17.9093C5.47244 17.8816 5.46865 17.814 5.51216 17.7816C5.63797 17.6873 5.76382 17.5893 5.88396 17.4902C5.90569 17.4721 5.93598 17.4683 5.96153 17.4797C9.88928 19.273 14.1415 19.273 18.023 17.4797C18.0485 17.4674 18.0788 17.4712 18.1015 17.4893C18.2216 17.5883 18.3475 17.6873 18.4742 17.7816C18.5177 17.814 18.5149 17.8816 18.4676 17.9093C17.8697 18.2588 17.2482 18.554 16.5945 18.8006C16.552 18.8168 16.533 18.8664 16.5538 18.9073C16.9143 19.6054 17.3258 20.2701 17.7789 20.9005C17.7978 20.9272 17.8319 20.9386 17.8631 20.9291C19.8241 20.3225 21.8126 19.4073 23.8654 17.8997C23.8834 17.8864 23.8948 17.8664 23.8967 17.8445C24.3971 12.6676 23.0585 8.17064 20.3482 4.18414C20.3416 4.17082 20.3303 4.16129 20.317 4.15557ZM8.02002 15.117C6.8375 15.117 5.86313 14.0313 5.86313 12.6981C5.86313 11.3648 6.8186 10.2791 8.02002 10.2791C9.23087 10.2791 10.1958 11.3743 10.1769 12.6981C10.1769 14.0313 9.22141 15.117 8.02002 15.117ZM15.9947 15.117C14.8123 15.117 13.8379 14.0313 13.8379 12.6981C13.8379 11.3648 14.7933 10.2791 15.9947 10.2791C17.2056 10.2791 18.1705 11.3743 18.1516 12.6981C18.1516 14.0313 17.2056 15.117 15.9947 15.117Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="black" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12C2 16.418 4.865 20.166 8.84 21.49C9.34 21.58 9.52 21.27 9.52 21C9.52 20.77 9.51 20.14 9.51 19.31C6.73 19.91 6.14 17.97 6.14 17.97C5.68 16.81 5.03 16.5 5.03 16.5C4.12 15.88 5.1 15.9 5.1 15.9C6.1 15.97 6.63 16.93 6.63 16.93C7.5 18.45 8.97 18 9.54 17.76C9.63 17.11 9.89 16.67 10.17 16.42C7.95 16.17 5.62 15.31 5.62 11.5C5.62 10.39 6 9.5 6.65 8.79C6.55 8.54 6.2 7.5 6.75 6.15C6.75 6.15 7.59 5.88 9.5 7.17C10.29 6.95 11.15 6.84 12 6.84C12.85 6.84 13.71 6.95 14.5 7.17C16.41 5.88 17.25 6.15 17.25 6.15C17.8 7.5 17.45 8.54 17.35 8.79C18 9.5 18.38 10.39 18.38 11.5C18.38 15.32 16.04 16.16 13.81 16.41C14.17 16.72 14.5 17.33 14.5 18.26C14.5 19.6 14.49 20.68 14.49 21C14.49 21.27 14.67 21.59 15.17 21.49C19.14 20.16 22 16.42 22 12C22 6.477 17.523 2 12 2Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 902 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2001_1428)">
|
||||
<path d="M22.2234 0H1.77187C0.792187 0 0 0.773438 0 1.72969V22.2656C0 23.2219 0.792187 24 1.77187 24H22.2234C23.2031 24 24 23.2219 24 22.2703V1.72969C24 0.773438 23.2031 0 22.2234 0ZM7.12031 20.4516H3.55781V8.99531H7.12031V20.4516ZM5.33906 7.43438C4.19531 7.43438 3.27188 6.51094 3.27188 5.37187C3.27188 4.23281 4.19531 3.30937 5.33906 3.30937C6.47813 3.30937 7.40156 4.23281 7.40156 5.37187C7.40156 6.50625 6.47813 7.43438 5.33906 7.43438ZM20.4516 20.4516H16.8937V14.8828C16.8937 13.5562 16.8703 11.8453 15.0422 11.8453C13.1906 11.8453 12.9094 13.2937 12.9094 14.7891V20.4516H9.35625V8.99531H12.7687V10.5609H12.8156C13.2891 9.66094 14.4516 8.70938 16.1813 8.70938C19.7859 8.70938 20.4516 11.0813 20.4516 14.1656V20.4516Z" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2001_1428">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 989 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.0512 4.07466C15.3113 5.17727 16.301 5.99866 17.4829 5.99866C18.8627 5.99866 19.9813 4.87965 19.9813 3.49933C19.9813 2.11902 18.8627 1 17.4829 1C16.2764 1 15.2703 1.85537 15.036 2.99314C13.0155 3.20991 11.4378 4.92417 11.4378 7.00167C11.4378 7.00636 11.4378 7.00988 11.4378 7.01456C9.24041 7.10713 7.23397 7.73284 5.641 8.72062C5.04949 8.26247 4.30688 7.98945 3.50102 7.98945C1.5672 7.98945 0 9.55725 0 11.4918C0 12.8955 0.824597 14.1048 2.01581 14.6637C2.13177 18.7297 6.56047 22 12.0082 22C17.4559 22 21.8905 18.7261 22.0006 14.6567C23.1824 14.0942 24 12.8885 24 11.493C24 9.55842 22.4328 7.99063 20.499 7.99063C19.6966 7.99063 18.9575 8.2613 18.3672 8.71594C16.7602 7.72113 14.7315 7.09541 12.5119 7.01222C12.5119 7.0087 12.5119 7.00636 12.5119 7.00285C12.5119 5.51473 13.6176 4.27971 15.0512 4.077V4.07466ZM5.50044 13.7146C5.559 12.4444 6.40234 11.4695 7.38272 11.4695C8.3631 11.4695 9.11274 12.4995 9.05417 13.7697C8.99561 15.0398 8.26354 15.5015 7.28199 15.5015C6.30044 15.5015 5.44187 14.9848 5.50044 13.7146ZM16.6348 11.4695C17.6164 11.4695 18.4597 12.4444 18.5171 13.7146C18.5757 14.9848 17.716 15.5015 16.7356 15.5015C15.7552 15.5015 15.022 15.041 14.9634 13.7697C14.9048 12.4995 15.6533 11.4695 16.6348 11.4695ZM15.4682 16.6533C15.6521 16.6721 15.7693 16.8631 15.6978 17.0341C15.0946 18.4766 13.6703 19.4901 12.0082 19.4901C10.3461 19.4901 8.92299 18.4766 8.31859 17.0341C8.24714 16.8631 8.36427 16.6721 8.54817 16.6533C9.62577 16.5444 10.7912 16.4846 12.0082 16.4846C13.2252 16.4846 14.3895 16.5444 15.4682 16.6533Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3263 1.90393H21.6998L14.3297 10.3274L23 21.7899H16.2112L10.894 14.838L4.80995 21.7899H1.43443L9.31743 12.78L1 1.90393H7.96111L12.7674 8.25826L18.3263 1.90393ZM17.1423 19.7707H19.0116L6.94539 3.81706H4.93946L17.1423 19.7707Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 358 B |
+66
-76
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -28,19 +28,19 @@
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "info",
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "info",
|
||||
"noEmptyPattern": "off",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "info",
|
||||
"useYield": "info",
|
||||
"useHookAtTopLevel": "off",
|
||||
"useYield": "off",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "info",
|
||||
"a11y": "off",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
@@ -51,36 +51,35 @@
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "info",
|
||||
"noNonNullAssertion": "off",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSelfClosingElements": "off",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "off",
|
||||
"noUselessElse": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "info",
|
||||
"noAsyncPromiseExecutor": "off",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "info",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noExplicitAny": "off",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "info",
|
||||
"useIterableCallbackReturn": "info"
|
||||
"noAssignInExpressions": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "info",
|
||||
"useOptionalChain": "info",
|
||||
"noBannedTypes": "warn",
|
||||
"useLiteralKeys": "info",
|
||||
"noUselessCatch": "info",
|
||||
"noUselessSwitchCase": "info",
|
||||
"noStaticOnlyClass": "info"
|
||||
"noUselessConstructor": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noBannedTypes": "off",
|
||||
"useLiteralKeys": "off",
|
||||
"noUselessCatch": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noStaticOnlyClass": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
@@ -95,11 +94,6 @@
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
@@ -118,21 +112,19 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
// 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"
|
||||
"!**/dist",
|
||||
"!**/dist-*",
|
||||
"!**/out",
|
||||
"!**/evals",
|
||||
"!**/playwright",
|
||||
"!**/test-results",
|
||||
"!**/node_modules",
|
||||
"!**/webview-ui/build",
|
||||
"!**/generated",
|
||||
"!**/proto",
|
||||
"!**/tests/specs"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
@@ -142,15 +134,14 @@
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/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"
|
||||
"!**/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"
|
||||
@@ -163,37 +154,36 @@
|
||||
],
|
||||
"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/**",
|
||||
"!**/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-ts/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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cline CLI
|
||||
# Cline CLI (TypeScript)
|
||||
|
||||
The official CLI for Cline. Run Cline tasks directly from the terminal with the same underlying functionality as the VS Code extension.
|
||||
A TypeScript CLI implementation of Cline that reuses the core TypeScript codebase. This allows you to run Cline tasks directly from the terminal while sharing the same underlying functionality as the VS Code extension.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -27,8 +27,23 @@ npm run install:all
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build the CLI
|
||||
npm run build:cli
|
||||
```
|
||||
|
||||
Or install the CLI globally:
|
||||
|
||||
```bash
|
||||
# Install all dependencies first
|
||||
npm run install:all
|
||||
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build and link the CLI globally
|
||||
npm run cli:link
|
||||
cd cli-ts
|
||||
npm install
|
||||
npm run link
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -188,51 +203,54 @@ These options are available for the default command (running a task directly):
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
For active development, at the root of this repo:
|
||||
|
||||
1. **Initial setup:**
|
||||
```bash
|
||||
npm run install:all
|
||||
npm run protos
|
||||
```
|
||||
|
||||
2. **Make changes to cli-ts:**
|
||||
```bash
|
||||
npm run cli:dev
|
||||
# or
|
||||
cd cli-ts && npm run dev
|
||||
```
|
||||
|
||||
3. **Test your changes:**
|
||||
```bash
|
||||
cline [your-command] # In a new Terminal
|
||||
```
|
||||
|
||||
4. **When done:**
|
||||
```bash
|
||||
npm run cli:unlink
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# 1. Install all dependencies (root, webview-ui, cli)
|
||||
npm run install:all
|
||||
cd cli-ts
|
||||
|
||||
# 2. Build and link globally so you can run `cline` from anywhere
|
||||
npm run cli:link
|
||||
# Development build with source maps
|
||||
npm run build
|
||||
|
||||
# 3. Test it
|
||||
cline --help
|
||||
# Production build (minified)
|
||||
npm run build:production
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run install:all` | Install deps for root, webview-ui, and cli |
|
||||
| `npm run cli:build` | Generate protos and build CLI |
|
||||
| `npm run cli:build:production` | Production build (minified) |
|
||||
| `npm run cli:link` | Build and `npm link` so you can run `cline` from anywhere |
|
||||
| `npm run cli:unlink` | Remove the global `cline` symlink |
|
||||
| `npm run cli:dev` | Link + watch mode for development |
|
||||
| `npm run cli:watch` | Watch mode only (no initial build) |
|
||||
| `npm run cli:test` | Run CLI tests |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Run `npm run cli:dev` - this links the CLI globally and starts watch mode
|
||||
2. Make changes to files in `cli/src/`
|
||||
3. The build automatically rebuilds on save
|
||||
4. Test your changes by running `cline` in another terminal
|
||||
5. When done, run `npm run cli:unlink` to clean up
|
||||
|
||||
### Proto Generation
|
||||
|
||||
The CLI uses proto-generated types for message passing (same as the VS Code extension). If you modify any `.proto` files, run:
|
||||
### Watch Mode
|
||||
|
||||
```bash
|
||||
npm run protos
|
||||
npm run watch
|
||||
```
|
||||
|
||||
This generates TypeScript types in `src/generated/` that both the CLI and extension use.
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
@@ -250,7 +268,7 @@ npm run update-brew-formula
|
||||
```bash
|
||||
# Create a local tap
|
||||
brew tap-new cline/local
|
||||
cp ./cli/cline.rb "$(brew --repository)/Library/Taps/cline/homebrew-local/Formula/cline.rb"
|
||||
cp ./cli-ts/cline.rb "$(brew --repository)/Library/Taps/cline/homebrew-local/Formula/cline.rb"
|
||||
|
||||
# Build from Source
|
||||
brew install --build-from-source cline/local/cline
|
||||
@@ -271,50 +289,19 @@ git push
|
||||
|
||||
## Architecture
|
||||
|
||||
### How It Works
|
||||
The CLI reuses the core Cline TypeScript codebase:
|
||||
|
||||
The CLI directly imports and reuses the core Cline TypeScript codebase (the same code that powers the VS Code extension). This means feature parity is easy to maintain - when core gets updated, the CLI automatically benefits.
|
||||
- **Controller** (`@core/controller`): Manages task lifecycle and state
|
||||
- **Task** (`@core/task`): Executes Cline tasks using the AI API
|
||||
- **StateManager** (`@core/storage`): Handles persistent state storage
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ CLI (cli/) │
|
||||
│ - React Ink terminal UI │
|
||||
│ - Command parsing (commander) │
|
||||
│ - Terminal-specific adapters │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ direct imports
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Core (src/core/) │
|
||||
│ - Controller: task lifecycle, state management │
|
||||
│ - Task: AI API calls, tool execution │
|
||||
│ - StateManager: persistent storage │
|
||||
│ - Proto types: message definitions │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
CLI-specific implementations:
|
||||
|
||||
Unlike a client-server architecture, the CLI runs everything in a single Node.js process. The "host bridge" pattern provides terminal-appropriate implementations for things the VS Code extension would handle differently (clipboard, file dialogs, etc.).
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Entry point, command definitions |
|
||||
| `src/components/App.tsx` | Main React Ink app |
|
||||
| `src/components/ChatView.tsx` | Task conversation UI |
|
||||
| `src/controllers/CliWebviewProvider.ts` | Bridges core messages to terminal output |
|
||||
| `src/vscode-context.ts` | Mock VS Code extension context for core compatibility |
|
||||
| `src/vscode-shim.ts` | Shims for VS Code APIs that core depends on |
|
||||
| `src/constants/colors.ts` | Terminal color definitions |
|
||||
|
||||
### React Ink
|
||||
|
||||
The CLI uses [React Ink](https://github.com/vadimdemedes/ink) for its terminal UI. This lets us build the interface with React components that render to the terminal. Key patterns:
|
||||
|
||||
- Components in `src/components/` render terminal UI
|
||||
- Hooks in `src/hooks/` manage terminal-specific state (size, scrolling)
|
||||
- The `useStateSubscriber` hook subscribes to core state changes
|
||||
- `cli-host-bridge.ts`: CLI implementations of host bridge services
|
||||
- `cli-webview-provider.ts`: WebviewProvider that outputs to terminal
|
||||
- `cli-comment-review.ts`: Comment review controller for terminal
|
||||
- `vscode-context.ts`: Mock VSCode extension context
|
||||
- `display.ts`: Terminal output formatting utilities
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -327,39 +314,41 @@ The CLI stores its data in `~/.cline/data/` by default:
|
||||
|
||||
Override with the `--config` option or `CLINE_DIR` environment variable.
|
||||
|
||||
## Comparison with Go CLI
|
||||
|
||||
This TypeScript CLI differs from the Go CLI (`cli/` directory):
|
||||
|
||||
| Feature | Go CLI | TypeScript CLI |
|
||||
|---------|--------|----------------|
|
||||
| Language | Go | TypeScript |
|
||||
| Core sharing | Uses gRPC to communicate | Direct imports |
|
||||
| Startup time | Fast | Moderate |
|
||||
| Dependencies | Standalone binary | Requires Node.js |
|
||||
| Best for | Production deployment | Development, debugging |
|
||||
|
||||
Choose the TypeScript CLI when you need to debug or modify the core Cline logic. Choose the Go CLI for production deployment with faster startup.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
If you encounter build errors:
|
||||
If you encounter build errors, ensure you've:
|
||||
1. Run `npm install` in the repository root
|
||||
2. Run `npm run protos` to generate proto files
|
||||
3. Have all peer dependencies installed
|
||||
|
||||
### Missing Dependencies
|
||||
|
||||
The CLI imports from the parent project. If you see import errors:
|
||||
```bash
|
||||
# Make sure all deps are installed
|
||||
npm run install:all
|
||||
|
||||
# Regenerate proto types
|
||||
cd .. # Go to repository root
|
||||
npm install
|
||||
npm run protos
|
||||
|
||||
# Then rebuild
|
||||
npm run cli:build
|
||||
```
|
||||
|
||||
### "command not found: cline"
|
||||
|
||||
The CLI isn't linked globally. Run:
|
||||
### Permission Denied
|
||||
|
||||
Make the CLI executable:
|
||||
```bash
|
||||
npm run cli:link
|
||||
```
|
||||
|
||||
### Changes Not Reflected
|
||||
|
||||
If your code changes aren't showing up:
|
||||
|
||||
1. Make sure watch mode is running (`npm run cli:dev`)
|
||||
2. Check for TypeScript errors in the watch output
|
||||
3. Try unlinking and relinking: `npm run cli:unlink && npm run cli:link`
|
||||
|
||||
### Import Errors from Core
|
||||
|
||||
The CLI imports from `@core/`, `@shared/`, etc. These paths are defined in the root `tsconfig.json`. If you see import errors, make sure you're building from the repo root, not from inside `cli/`.
|
||||
chmod +x dist/cli.js
|
||||
```
|
||||
@@ -7,7 +7,6 @@ class Cline < Formula
|
||||
license "Apache-2.0"
|
||||
|
||||
depends_on "node@20"
|
||||
depends_on "ripgrep"
|
||||
|
||||
def install
|
||||
system "npm", "install", *std_npm_args(prefix: false)
|
||||
@@ -102,7 +102,7 @@ const esbuildProblemMatcherPlugin: esbuild.Plugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[cli esbuild] Build started...")
|
||||
console.log("[cli-ts esbuild] Build started...")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
@@ -111,7 +111,7 @@ const esbuildProblemMatcherPlugin: esbuild.Plugin = {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[cli esbuild] Build finished")
|
||||
console.log("[cli-ts esbuild] Build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -199,7 +199,7 @@ const buildTimeEnvs = [
|
||||
|
||||
buildTimeEnvs.forEach((envVar) => {
|
||||
if (process.env[envVar]) {
|
||||
console.log(`[cli esbuild] ${envVar} env var is set`)
|
||||
console.log(`[cli-ts esbuild] ${envVar} env var is set`)
|
||||
buildEnvVars[`process.env.${envVar}`] = JSON.stringify(process.env[envVar])
|
||||
}
|
||||
})
|
||||
@@ -234,7 +234,6 @@ const config: esbuild.BuildOptions = {
|
||||
"aws4fetch",
|
||||
"pino",
|
||||
"pino-roll",
|
||||
"@vscode/ripgrep", // Uses __dirname to locate the binary
|
||||
],
|
||||
supported: { "top-level-await": true },
|
||||
banner: {
|
||||
@@ -254,7 +253,7 @@ async function main() {
|
||||
const ctx = await esbuild.context(config)
|
||||
if (watch) {
|
||||
await ctx.watch()
|
||||
console.log("[cli] Watching for changes...")
|
||||
console.log("[cli-ts] Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await ctx.dispose()
|
||||
@@ -0,0 +1,384 @@
|
||||
.\" Automatically generated by Pandoc 3.8.3
|
||||
.\"
|
||||
.TH "CLINE" "1" "January 2026" "Cline CLI 2.0" "User Commands"
|
||||
.SH NAME
|
||||
cline \- AI coding assistant in your terminal
|
||||
.SH SYNOPSIS
|
||||
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
|
||||
[\f[I]arguments\f[R]]
|
||||
.SH DESCRIPTION
|
||||
\f[B]cline\f[R] is a command\-line interface for the Cline AI coding
|
||||
assistant.
|
||||
It provides the same powerful AI capabilities as the VS Code extension,
|
||||
directly in your terminal.
|
||||
.PP
|
||||
Cline is an autonomous AI agent that can read, write, and execute code
|
||||
across your projects.
|
||||
He can create and edit files, run terminal commands, use a headless
|
||||
browser, and more\(emall while asking for your approval before taking
|
||||
actions.
|
||||
.PP
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and
|
||||
plain text mode (for piped input and scripted workflows).
|
||||
.SH MODES OF OPERATION
|
||||
\f[B]Interactive Mode\f[R] : When you run \f[B]cline\f[R] without
|
||||
arguments, it launches an interactive welcome prompt with a rich
|
||||
terminal UI.
|
||||
You can type your task, view conversation history, and interact with
|
||||
Cline in real\-time.
|
||||
.PP
|
||||
\f[B]Task Mode\f[R] : Run \f[B]cline \(lqprompt\(rq\f[R] or \f[B]cline
|
||||
task \(lqprompt\(rq\f[R] to immediately start a task.
|
||||
If stdin is a TTY, you\(cqll see the interactive UI.
|
||||
If stdin is piped or output is redirected, the CLI automatically
|
||||
switches to plain text mode.
|
||||
.PP
|
||||
\f[B]Plain Text Mode\f[R] : Activated automatically when stdin is piped,
|
||||
output is redirected, or \f[B]\-\-json\f[R]/\f[B]\-\-yolo\f[R] flags are
|
||||
used.
|
||||
Outputs clean text without the Ink UI, suitable for scripting and CI/CD
|
||||
pipelines.
|
||||
.SH AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
.PP
|
||||
\f[B]ACT MODE\f[R] : 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.
|
||||
.PP
|
||||
\f[B]PLAN MODE\f[R] : 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.
|
||||
.SH COMMANDS
|
||||
.SS task (alias: t)
|
||||
Run a new task with a prompt.
|
||||
.PP
|
||||
\f[B]cline task\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline t\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]] : Create and run
|
||||
a new task.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo/yes mode (auto\-approve
|
||||
all actions, output in plain mode, exit process automatically when task
|
||||
complete)
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-i\f[R], \f[B]\-\-images\f[R] \f[I]paths\&...\f[R] : Image file
|
||||
paths to include with the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output including
|
||||
reasoning
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.PP
|
||||
\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
|
||||
.SS history (alias: h)
|
||||
List task history with pagination.
|
||||
.PP
|
||||
\f[B]cline history\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline h\f[R] [\f[I]options\f[R]] : Display previous tasks.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-n\f[R], \f[B]\-\-limit\f[R] \f[I]number\f[R] : Number of tasks to
|
||||
show (default: 10)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-page\f[R] \f[I]number\f[R] : Page number,
|
||||
1\-based (default: 1)
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS config
|
||||
Show current configuration.
|
||||
.PP
|
||||
\f[B]cline config\f[R] [\f[I]options\f[R]] : Display global and
|
||||
workspace state.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS auth
|
||||
Authenticate a provider and configure the model.
|
||||
.PP
|
||||
\f[B]cline auth\f[R] [\f[I]options\f[R]] : Launch interactive
|
||||
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)
|
||||
.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)
|
||||
.PP
|
||||
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
|
||||
for OpenAI\-compatible providers)
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS update
|
||||
Check for updates and install if available.
|
||||
.PP
|
||||
\f[B]cline update\f[R] [\f[I]options\f[R]] : Check npm for newer
|
||||
versions.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.SS version
|
||||
Show the CLI version number.
|
||||
.PP
|
||||
\f[B]cline version\f[R]
|
||||
.SS dev
|
||||
Developer tools and utilities.
|
||||
.PP
|
||||
\f[B]cline dev log\f[R] : Open the log file for debugging.
|
||||
.SH DEFAULT COMMAND OPTIONS
|
||||
When running \f[B]cline\f[R] with just a prompt (no subcommand), these
|
||||
options are available:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo mode (auto\-approve all
|
||||
actions).
|
||||
Also forces plain text output mode.
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Configuration directory
|
||||
.PP
|
||||
\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.
|
||||
Forces plain text mode.
|
||||
.SH JSON OUTPUT FORMAT
|
||||
When using \f[B]\-\-json\f[R], each message is output as a JSON object
|
||||
with these fields:
|
||||
.PP
|
||||
\f[B]Required fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]type\f[R]: \(lqask\(rq or \(lqsay\(rq
|
||||
.IP \(bu 2
|
||||
\f[B]text\f[R]: message text
|
||||
.IP \(bu 2
|
||||
\f[B]ts\f[R]: Unix epoch timestamp in milliseconds
|
||||
.PP
|
||||
\f[B]Optional fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]reasoning\f[R]: reasoning text
|
||||
.IP \(bu 2
|
||||
\f[B]say\f[R]: say subtype (when type is \(lqsay\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]ask\f[R]: ask subtype (when type is \(lqask\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]partial\f[R]: streaming flag
|
||||
.IP \(bu 2
|
||||
\f[B]images\f[R]: list of image URIs
|
||||
.IP \(bu 2
|
||||
\f[B]files\f[R]: list of file paths
|
||||
.SH EXAMPLES
|
||||
.SS Basic Usage
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Launch interactive mode\f[R]
|
||||
cline
|
||||
|
||||
\f[I]# Run a task directly\f[R]
|
||||
cline \(dqCreate a hello world function in Python\(dq
|
||||
|
||||
\f[I]# Run with verbose output and extended thinking\f[R]
|
||||
cline \-v \-\-thinking \(dqAnalyze this codebase architecture\(dq
|
||||
.EE
|
||||
.SS Mode Selection
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Run in plan mode (gather info before acting)\f[R]
|
||||
cline \-p \(dqDesign a REST API for user management\(dq
|
||||
|
||||
\f[I]# Run in act mode with auto\-approval (yolo)\f[R]
|
||||
cline \-y \(dqFix the typo in README.md\(dq
|
||||
.EE
|
||||
.SS Using Specific Models
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Use a specific model\f[R]
|
||||
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
|
||||
.EE
|
||||
.SS Including Images
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Include images with explicit flag\f[R]
|
||||
cline task \-i screenshot.png diagram.jpg \(dqFix the UI based on these images\(dq
|
||||
|
||||
\f[I]# Or use inline image references in the prompt\f[R]
|
||||
cline \(dqFix the layout shown in \(at./screenshot.png\(dq
|
||||
.EE
|
||||
.SS Piped Input
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Pipe file contents to Cline\f[R]
|
||||
cat README.md \f[B]|\f[R] cline \(dqSummarize this document\(dq
|
||||
|
||||
\f[I]# Pipe with additional prompt\f[R]
|
||||
echo \(dqfunction add(a, b) { return a + b }\(dq \f[B]|\f[R] cline \(dqAdd TypeScript types to this\(dq
|
||||
|
||||
\f[I]# Combine piped input with a prompt\f[R]
|
||||
git diff \f[B]|\f[R] cline \(dqReview these changes and suggest improvements\(dq
|
||||
.EE
|
||||
.SS Scripting and Automation
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# JSON output for parsing\f[R]
|
||||
cline \-\-json \(dqWhat files are in this directory?\(dq \f[B]|\f[R] jq \(aq.text\(aq
|
||||
|
||||
\f[I]# Yolo mode for automated workflows (auto\-approves all actions), forces plain text output\f[R]
|
||||
cline \-y \(dqRun the test suite and fix any failures\(dq
|
||||
.EE
|
||||
.SS Task History
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# List recent tasks\f[R]
|
||||
cline history
|
||||
|
||||
\f[I]# Show more tasks with pagination\f[R]
|
||||
cline history \-n 20 \-p 2
|
||||
.EE
|
||||
.SS Authentication
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Interactive authentication wizard\f[R]
|
||||
cline auth
|
||||
|
||||
\f[I]# Quick setup for Anthropic\f[R]
|
||||
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]# OpenAI\-compatible provider with custom base URL\f[R]
|
||||
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
|
||||
.EE
|
||||
.SH ENVIRONMENT
|
||||
\f[B]CLINE_DIR\f[R] : Override the default configuration directory.
|
||||
When set, Cline stores all data in this directory instead of
|
||||
\f[CR]\(ti/.cline/data/\f[R].
|
||||
.PP
|
||||
\f[B]CLINE_COMMAND_PERMISSIONS\f[R] : JSON configuration for restricting
|
||||
which shell commands Cline can execute.
|
||||
When set, commands are validated against allow/deny patternks before
|
||||
execution.
|
||||
When not set, all commands are allowed.
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{\(dqallow\(dq: [\(dqpattern1\(dq, \(dqpattern2\(dq], \(dqdeny\(dq: [\(dqpattern3\(dq], \(dqallowRedirects\(dq: true}\f[R]
|
||||
.PP
|
||||
\f[B]Fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
|
||||
If specified, only matching commands are permitted.
|
||||
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
|
||||
single character.
|
||||
Setting allow on anything will deny all others.
|
||||
.IP \(bu 2
|
||||
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
|
||||
Deny rules take precedence over allow rules.
|
||||
.IP \(bu 2
|
||||
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
|
||||
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
|
||||
Defaults to false.
|
||||
.PP
|
||||
\f[B]Rule evaluation:\f[R]
|
||||
.IP "1." 3
|
||||
Check for dangerous characters (backticks outside single quotes,
|
||||
unquoted newlines)
|
||||
.IP "2." 3
|
||||
Parse command into segments split by operators (\f[CR]&&\f[R],
|
||||
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
|
||||
.IP "3." 3
|
||||
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
|
||||
is denied
|
||||
.IP "4." 3
|
||||
Each segment is validated against deny rules first, then allow rules
|
||||
.IP "5." 3
|
||||
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
|
||||
recursively validated
|
||||
.IP "6." 3
|
||||
All segments must pass for the command to be allowed
|
||||
.PP
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq, \(dqnode *\(dq], \(dqdeny\(dq: [\(dqrm \-rf *\(dq, \(dqsudo *\(dq]}\(aq
|
||||
|
||||
\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:
|
||||
.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].
|
||||
.SH BUGS
|
||||
Report bugs at: \c
|
||||
.UR https://github.com/cline/cline/issues
|
||||
.UE \c
|
||||
.PP
|
||||
For real\-time help, join the Discord community at: \c
|
||||
.UR https://discord.gg/cline
|
||||
.UE \c
|
||||
.SH SEE ALSO
|
||||
Full documentation: \c
|
||||
.UR https://docs.cline.bot
|
||||
.UE \c
|
||||
.PP
|
||||
VS Code extension: \c
|
||||
.UR https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
|
||||
.UE \c
|
||||
.SH AUTHORS
|
||||
Cline is developed by Cline Bot Inc.\ and the open source community.
|
||||
.SH COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: CLINE
|
||||
section: 1
|
||||
header: User Commands
|
||||
footer: Cline CLI 2.0
|
||||
date: January 2026
|
||||
---
|
||||
|
||||
# NAME
|
||||
|
||||
cline - AI coding assistant in your terminal
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**cline** [*prompt*] [*options*]
|
||||
|
||||
**cline** *command* [*options*] [*arguments*]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
**cline** is a command-line interface for the Cline AI coding assistant. It provides the same powerful AI capabilities as the VS Code extension, directly in your terminal.
|
||||
|
||||
Cline is an autonomous AI agent that can read, write, and execute code across your projects. He can create and edit files, run terminal commands, use a headless browser, and more—all while asking for your approval before taking actions.
|
||||
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and plain text mode (for piped input and scripted workflows).
|
||||
|
||||
# MODES OF OPERATION
|
||||
|
||||
**Interactive Mode** : When you run **cline** without arguments, it launches an interactive welcome prompt with a rich terminal UI. You can type your task, view conversation history, and interact with Cline in real-time.
|
||||
|
||||
**Task Mode** : Run **cline "prompt"** or **cline task "prompt"** to immediately start a task. If stdin is a TTY, you'll see the interactive UI. If stdin is piped or output is redirected, the CLI automatically switches to plain text mode.
|
||||
|
||||
**Plain Text Mode** : Activated automatically when stdin is piped, output is redirected, or **\--json**/**\--yolo** flags are used. Outputs clean text without the Ink UI, suitable for scripting and CI/CD pipelines.
|
||||
|
||||
# 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.
|
||||
|
||||
# COMMANDS
|
||||
|
||||
## task (alias: t)
|
||||
|
||||
Run a new task with a prompt.
|
||||
|
||||
**cline task** *prompt* [*options*]
|
||||
|
||||
**cline t** *prompt* [*options*] : Create and run a new task. Options:
|
||||
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output including reasoning
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory for the task
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text
|
||||
|
||||
## history (alias: h)
|
||||
|
||||
List task history with pagination.
|
||||
|
||||
**cline history** [*options*]
|
||||
|
||||
**cline h** [*options*] : Display previous tasks. Options:
|
||||
|
||||
**-n**, **\--limit** *number* : Number of tasks to show (default: 10)
|
||||
|
||||
**-p**, **\--page** *number* : Page number, 1-based (default: 1)
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## config
|
||||
|
||||
Show current configuration.
|
||||
|
||||
**cline config** [*options*] : Display global and workspace state. Options:
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## auth
|
||||
|
||||
Authenticate a provider and configure the model.
|
||||
|
||||
**cline auth** [*options*] : Launch interactive authentication wizard, or use quick setup flags. Options:
|
||||
|
||||
**-p**, **\--provider** *id* : Provider ID for quick setup (e.g., openai-native, anthropic, openrouter)
|
||||
|
||||
**-k**, **\--apikey** *key* : API key for the provider
|
||||
|
||||
**-m**, **\--modelid** *id* : Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)
|
||||
|
||||
**-b**, **\--baseurl** *url* : Base URL (optional, for OpenAI-compatible providers)
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## update
|
||||
|
||||
Check for updates and install if available.
|
||||
|
||||
**cline update** [*options*] : Check npm for newer versions. Options:
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
## version
|
||||
|
||||
Show the CLI version number.
|
||||
|
||||
**cline version**
|
||||
|
||||
## dev
|
||||
|
||||
Developer tools and utilities.
|
||||
|
||||
**cline dev log** : Open the log file for debugging.
|
||||
|
||||
# DEFAULT COMMAND OPTIONS
|
||||
|
||||
When running **cline** with just a prompt (no subcommand), these options are available:
|
||||
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**\--config** *path* : Configuration directory
|
||||
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
|
||||
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
|
||||
**Required fields:**
|
||||
|
||||
- **type**: "ask" or "say"
|
||||
- **text**: message text
|
||||
- **ts**: Unix epoch timestamp in milliseconds
|
||||
|
||||
**Optional fields:**
|
||||
|
||||
- **reasoning**: reasoning text
|
||||
- **say**: say subtype (when type is "say")
|
||||
- **ask**: ask subtype (when type is "ask")
|
||||
- **partial**: streaming flag
|
||||
- **images**: list of image URIs
|
||||
- **files**: list of file paths
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# Run with verbose output and extended thinking
|
||||
cline -v --thinking "Analyze this codebase architecture"
|
||||
```
|
||||
|
||||
## Mode Selection
|
||||
|
||||
```bash
|
||||
# Run in plan mode (gather info before acting)
|
||||
cline -p "Design a REST API for user management"
|
||||
|
||||
# Run in act mode with auto-approval (yolo)
|
||||
cline -y "Fix the typo in README.md"
|
||||
```
|
||||
|
||||
## Using Specific Models
|
||||
|
||||
```bash
|
||||
# Use a specific model
|
||||
cline -m claude-sonnet-4-5-20250929 "Refactor this function"
|
||||
|
||||
# Quick auth setup with model
|
||||
cline auth -p anthropic -k sk-ant-xxxxx -m claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
## Including Images
|
||||
|
||||
```bash
|
||||
# Include images with explicit flag
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline "Fix the layout shown in @./screenshot.png"
|
||||
```
|
||||
|
||||
## Piped Input
|
||||
|
||||
```bash
|
||||
# Pipe file contents to Cline
|
||||
cat README.md | cline "Summarize this document"
|
||||
|
||||
# Pipe with additional prompt
|
||||
echo "function add(a, b) { return a + b }" | cline "Add TypeScript types to this"
|
||||
|
||||
# Combine piped input with a prompt
|
||||
git diff | cline "Review these changes and suggest improvements"
|
||||
```
|
||||
|
||||
## Scripting and Automation
|
||||
|
||||
```bash
|
||||
# JSON output for parsing
|
||||
cline --json "What files are in this directory?" | jq '.text'
|
||||
|
||||
# Yolo mode for automated workflows (auto-approves all actions), forces plain text output
|
||||
cline -y "Run the test suite and fix any failures"
|
||||
```
|
||||
|
||||
## Task History
|
||||
|
||||
```bash
|
||||
# List recent tasks
|
||||
cline history
|
||||
|
||||
# Show more tasks with pagination
|
||||
cline history -n 20 -p 2
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Interactive authentication wizard
|
||||
cline auth
|
||||
|
||||
# Quick setup for Anthropic
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx
|
||||
|
||||
# Quick setup for OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS** : JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patternks before execution. When not set, all commands are allowed.
|
||||
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
|
||||
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
|
||||
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
|
||||
|
||||
**Rule evaluation:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects 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
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
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}'
|
||||
```
|
||||
|
||||
|
||||
# CONFIGURATION FILES
|
||||
|
||||
```
|
||||
~/.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
|
||||
```
|
||||
|
||||
View logs with `cline dev log`.
|
||||
|
||||
|
||||
# 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>
|
||||
|
||||
VS Code extension: <https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev>
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Cline is developed by Cline Bot Inc. and the open source community.
|
||||
|
||||
# COPYRIGHT
|
||||
|
||||
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
|
||||
Generated
+2950
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "npm run build:production",
|
||||
"package:brew": "node ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "node esbuild.mts",
|
||||
"build:production": "node esbuild.mts --production",
|
||||
"watch": "node esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g @cline/cli && npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"author": {
|
||||
"name": "Cline Bot Inc."
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline"
|
||||
},
|
||||
"homepage": "https://cline.bot",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^19.2.9",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-picture": "^1.3.3",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"pino": "^10.0.0",
|
||||
"pino-roll": "^4.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
-10
@@ -172,16 +172,6 @@ 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,7 +12,11 @@
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { TerminalHandle } from "@agentclientprotocol/sdk"
|
||||
import { DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT, PROCESS_HOT_TIMEOUT_NORMAL } from "@integrations/terminal/constants"
|
||||
import {
|
||||
DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT,
|
||||
DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT,
|
||||
PROCESS_HOT_TIMEOUT_NORMAL,
|
||||
} from "@integrations/terminal/constants"
|
||||
import type {
|
||||
ITerminal,
|
||||
ITerminalManager,
|
||||
@@ -138,12 +142,12 @@ export interface ManagedTerminal {
|
||||
* Wraps ACP terminal operations and emits events compatible with ITerminalProcess.
|
||||
*/
|
||||
class AcpTerminalProcess extends EventEmitter<TerminalProcessEvents> implements ITerminalProcess {
|
||||
isHot = false
|
||||
waitForShellIntegration = false
|
||||
isHot: boolean = false
|
||||
waitForShellIntegration: boolean = false
|
||||
|
||||
private _unretrievedOutput = ""
|
||||
private _continued = false
|
||||
private _completed = false
|
||||
private _unretrievedOutput: string = ""
|
||||
private _continued: boolean = false
|
||||
private _completed: boolean = false
|
||||
private _hotTimeout: NodeJS.Timeout | null = null
|
||||
private _exitWaitTimeout: NodeJS.Timeout | null = null
|
||||
private readonly manager: AcpTerminalManager
|
||||
@@ -393,7 +397,7 @@ export class AcpTerminalManager implements ITerminalManager {
|
||||
private readonly numericIdToStringId: Map<number, string> = new Map()
|
||||
|
||||
/** Next numeric ID to assign */
|
||||
private nextNumericId = 1
|
||||
private nextNumericId: number = 1
|
||||
|
||||
/** Active processes indexed by numeric terminal ID */
|
||||
private readonly processes: Map<number, AcpTerminalProcess> = new Map()
|
||||
@@ -402,8 +406,9 @@ export class AcpTerminalManager implements ITerminalManager {
|
||||
private readonly terminalInfos: Map<number, TerminalInfo> = new Map()
|
||||
|
||||
// Configuration options for ITerminalManager
|
||||
private terminalReuseEnabled = true
|
||||
private terminalReuseEnabled: boolean = true
|
||||
private terminalOutputLineLimit: number = DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT
|
||||
private subagentTerminalOutputLineLimit: number = DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT
|
||||
|
||||
/**
|
||||
* Creates a new AcpTerminalManager.
|
||||
@@ -662,6 +667,14 @@ 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
|
||||
@@ -674,10 +687,15 @@ 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): string {
|
||||
const limit = overrideLimit !== undefined ? overrideLimit : this.terminalOutputLineLimit
|
||||
processOutput(outputLines: string[], overrideLimit?: number, isSubagentCommand?: boolean): string {
|
||||
const limit = isSubagentCommand
|
||||
? overrideLimit !== undefined
|
||||
? overrideLimit
|
||||
: this.subagentTerminalOutputLineLimit
|
||||
: this.terminalOutputLineLimit
|
||||
|
||||
if (outputLines.length > limit) {
|
||||
const halfLimit = Math.floor(limit / 2)
|
||||
@@ -173,7 +173,7 @@ 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(this.ctx.EXTENSION_DIR)
|
||||
await ClineEndpoint.initialize()
|
||||
await StateManager.initialize(this.ctx.extensionContext)
|
||||
|
||||
return {
|
||||
@@ -246,8 +246,8 @@ export class ClineAgent implements acp.Agent {
|
||||
},
|
||||
hostBridgeClientProvider,
|
||||
(message: string) => Logger.info(message),
|
||||
async (path: string) => {
|
||||
return AuthHandler.getInstance().getCallbackUrl(path)
|
||||
async () => {
|
||||
return AuthHandler.getInstance().getCallbackUrl()
|
||||
},
|
||||
async () => "", // get binary location not needed in ACP mode
|
||||
this.ctx.EXTENSION_DIR,
|
||||
@@ -333,15 +333,14 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
// Get current provider and model for the mode
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as ApiProvider | undefined
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
|
||||
// Use provider-specific model ID key (e.g., cline uses actModeOpenRouterModelId)
|
||||
const modelKey = currentProvider ? getProviderModelIdKey(currentProvider, mode) : null
|
||||
const currentModelId = modelKey ? stateManager.getGlobalSettingsKey(modelKey) : undefined
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as ApiProvider | undefined
|
||||
const currentModelId = stateManager.getGlobalSettingsKey(modelKey) as string | undefined
|
||||
|
||||
// Build the current model ID in provider/model format
|
||||
const currentFullModelId =
|
||||
currentProvider && currentModelId ? `${currentProvider}/${currentModelId}` : currentProvider || ""
|
||||
currentProvider && currentModelId ? `${currentProvider}/${currentModelId}` : currentProvider || "anthropic"
|
||||
|
||||
// Get available models based on provider
|
||||
let modelIds: string[] = []
|
||||
@@ -399,11 +398,13 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Update provider for both modes
|
||||
// Update model for both plan and act modes (use the same model for both)
|
||||
stateManager.setGlobalState("actModeApiProvider", provider)
|
||||
stateManager.setGlobalState("actModeApiModelId", modelId)
|
||||
stateManager.setGlobalState("planModeApiProvider", provider)
|
||||
stateManager.setGlobalState("planModeApiModelId", modelId)
|
||||
|
||||
// Update model ID using provider-specific keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
// Also update the provider-specific model ID keys for both modes
|
||||
const actProviderModelKey = getProviderModelIdKey(provider, "act")
|
||||
if (actProviderModelKey) {
|
||||
stateManager.setGlobalState(actProviderModelKey, modelId)
|
||||
@@ -973,7 +974,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("/auth")
|
||||
callbackUrl = await authHandler.getCallbackUrl()
|
||||
Logger.debug("[ClineAgent] Callback URL ready:", callbackUrl)
|
||||
} catch (error) {
|
||||
Logger.error("[ClineAgent] Failed to get callback URL:", error)
|
||||
@@ -1158,8 +1159,8 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
if (currentProvider === "cline") {
|
||||
// For Cline provider, check if we have stored auth data
|
||||
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
|
||||
return values.some(Boolean)
|
||||
const authData = await secretStorage.get("cline:clineAccountId")
|
||||
return !!authData
|
||||
}
|
||||
|
||||
// For OpenAI Codex provider, check OAuth credentials
|
||||
@@ -1218,11 +1219,8 @@ export class ClineAgent implements acp.Agent {
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("actModeApiProvider", "openai-codex")
|
||||
stateManager.setGlobalState("planModeApiProvider", "openai-codex")
|
||||
// Use provider-specific model ID keys for consistency
|
||||
const actModelKey = getProviderModelIdKey("openai-codex", "act")
|
||||
const planModelKey = getProviderModelIdKey("openai-codex", "plan")
|
||||
if (actModelKey) stateManager.setGlobalState(actModelKey, openAiCodexDefaultModelId)
|
||||
if (planModelKey) stateManager.setGlobalState(planModelKey, openAiCodexDefaultModelId)
|
||||
stateManager.setGlobalState("actModeApiModelId", openAiCodexDefaultModelId)
|
||||
stateManager.setGlobalState("planModeApiModelId", openAiCodexDefaultModelId)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
return {}
|
||||
@@ -312,10 +312,6 @@ 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
|
||||
@@ -131,9 +131,9 @@ const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task",
|
||||
secondaryText: "Exit",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: "reject",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
resume_task: {
|
||||
sendingDisabled: false,
|
||||
@@ -155,9 +155,9 @@ const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task with Context",
|
||||
secondaryText: "Exit",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: "reject",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Streaming/partial states
|
||||
@@ -82,8 +82,9 @@ describe("App", () => {
|
||||
})
|
||||
|
||||
it("should render AuthView when view is auth", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="auth" />)
|
||||
const { lastFrame } = render(<App authQuickSetup={{ provider: "openai" }} controller={mockController} view="auth" />)
|
||||
expect(lastFrame()).toContain("AuthView")
|
||||
expect(lastFrame()).toContain("openai")
|
||||
})
|
||||
|
||||
it("should render ChatView when view is welcome", () => {
|
||||
@@ -81,6 +81,13 @@ interface AppProps {
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
|
||||
// For auth view
|
||||
authQuickSetup?: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
}
|
||||
// For welcome view
|
||||
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
|
||||
onWelcomeExit?: () => void
|
||||
@@ -88,6 +95,8 @@ interface AppProps {
|
||||
initialImages?: string[]
|
||||
// Stdin support
|
||||
isRawModeSupported?: boolean
|
||||
// Robot position (calculated before Ink mounts)
|
||||
robotTopRow?: number
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({
|
||||
@@ -126,11 +135,13 @@ export const App: React.FC<AppProps> = ({
|
||||
globalSkills,
|
||||
localSkills,
|
||||
onToggleSkill,
|
||||
authQuickSetup,
|
||||
onWelcomeSubmit,
|
||||
onWelcomeExit,
|
||||
initialPrompt,
|
||||
initialImages,
|
||||
isRawModeSupported = true,
|
||||
robotTopRow,
|
||||
}) => {
|
||||
const { resizeKey } = useTerminalSize()
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
@@ -229,6 +240,7 @@ export const App: React.FC<AppProps> = ({
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onNavigateToWelcome={handleNavigateToWelcome}
|
||||
quickSetup={authQuickSetup}
|
||||
/>
|
||||
)
|
||||
break
|
||||
@@ -247,6 +259,7 @@ export const App: React.FC<AppProps> = ({
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onExit={onWelcomeExit}
|
||||
robotTopRow={robotTopRow}
|
||||
taskId={selectedTaskId}
|
||||
/>
|
||||
)}
|
||||
@@ -34,7 +34,8 @@ type AsciiMotionCliProps = {
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
onReady?: (api: PlaybackAPI) => void;
|
||||
onInteraction?: () => void; // Called when user scrolls, clicks, or drags
|
||||
robotTopRow?: number; // Screen row where robot is rendered (calculated before Ink mounts)
|
||||
onScroll?: () => void; // Called when user scrolls (scroll wheel)
|
||||
};
|
||||
|
||||
const FRAMES: FrameData[] = [
|
||||
@@ -333364,12 +333365,12 @@ const FRAME_BOTTOM_RIGHT = 128;
|
||||
|
||||
export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
hasDarkBackground = true,
|
||||
onInteraction,
|
||||
robotTopRow,
|
||||
onScroll,
|
||||
}) => {
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
const [targetFrame, setTargetFrame] = useState(0);
|
||||
const [cursor, setCursor] = useState({ x: 0, y: 0 });
|
||||
const lastCursorUpdateRef = useRef(0);
|
||||
const { stdout } = useStdout();
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
@@ -333378,9 +333379,8 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
const faceX = Math.floor(terminalWidth / 2);
|
||||
// Robot height after cropping is ~12 rows, eyes are roughly halfway down
|
||||
const robotHeight = 12;
|
||||
// Robot is always rendered at the top of the terminal in welcome state.
|
||||
// faceY is the robot's eye level (row 2 + half robot height)
|
||||
const faceY = 2 + Math.floor(robotHeight / 2);
|
||||
// faceY is the robot's eye level - calculated from robotTopRow prop (set before Ink mounts)
|
||||
const faceY = robotTopRow !== undefined ? robotTopRow + Math.floor(robotHeight / 2) : null;
|
||||
|
||||
// Select color theme based on background
|
||||
const theme = useMemo(() => hasDarkBackground ? THEME_DARK : THEME_LIGHT, [hasDarkBackground]);
|
||||
@@ -333390,13 +333390,13 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
// Stop animation on terminal resize to prevent visual glitches
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
onInteraction?.();
|
||||
onScroll?.();
|
||||
};
|
||||
process.stdout.on("resize", handleResize);
|
||||
return () => {
|
||||
process.stdout.off("resize", handleResize);
|
||||
};
|
||||
}, [onInteraction]);
|
||||
}, [onScroll]);
|
||||
|
||||
// Mouse tracking - gracefully handle environments without tty support
|
||||
useEffect(() => {
|
||||
@@ -333417,26 +333417,15 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
const handleData = (data: Buffer) => {
|
||||
const str = data.toString();
|
||||
|
||||
// Parse mouse events: \x1b[<button;x;yM (M=press, m=release)
|
||||
// Parse mouse events: \x1b[<button;x;yM
|
||||
const mouseMatch = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
||||
if (mouseMatch) {
|
||||
const button = parseInt(mouseMatch[1], 10);
|
||||
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();
|
||||
if (now - lastCursorUpdateRef.current >= 50) {
|
||||
lastCursorUpdateRef.current = now;
|
||||
setCursor({ x: parseInt(mouseMatch[2], 10), y: parseInt(mouseMatch[3], 10) });
|
||||
// Button 64 = scroll up, 65 = scroll down
|
||||
if (button === 64 || button === 65) {
|
||||
onScroll?.();
|
||||
}
|
||||
setCursor({ x: parseInt(mouseMatch[2], 10), y: parseInt(mouseMatch[3], 10) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -333455,6 +333444,9 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
|
||||
// Map cursor position to target frame (continuous interpolation)
|
||||
useEffect(() => {
|
||||
// Wait until we know where the robot is on screen
|
||||
if (faceY === null) return;
|
||||
|
||||
const dx = cursor.x - faceX;
|
||||
const dy = cursor.y - faceY;
|
||||
|
||||
@@ -333577,8 +333569,7 @@ export const StaticRobotFrame: React.FC<{ hasDarkBackground?: boolean }> = ({
|
||||
const y = minY + originalIndex;
|
||||
const croppedRow = row.slice(minX, maxX + 1);
|
||||
// Manual centering for Static component compatibility
|
||||
// Use Math.round to match Ink's flexbox centering behavior
|
||||
const padding = Math.max(0, Math.round(((process.stdout.columns || 80) - robotWidth) / 2));
|
||||
const padding = Math.max(0, Math.floor(((process.stdout.columns || 80) - robotWidth) / 2));
|
||||
return (
|
||||
<Box key={y}>
|
||||
<Text>{" ".repeat(padding)}</Text>
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles different types of user interactions (text input, confirmations, choices)
|
||||
*/
|
||||
|
||||
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -371,73 +372,3 @@ 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 " "
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,34 +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 { openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { API_PROVIDERS_LIST, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { ProviderToApiKeyMap } from "@/shared/storage"
|
||||
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 { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
getFeaturedModelAtIndex,
|
||||
getFeaturedModelMaxIndex,
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getProviderLabel } from "./ProviderPicker"
|
||||
import { getProviderLabel, getProviderOrder } from "./ProviderPicker"
|
||||
|
||||
type AuthStep =
|
||||
| "menu"
|
||||
@@ -45,18 +35,26 @@ 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()
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
onNavigateToWelcome?: () => void
|
||||
// Quick setup options
|
||||
quickSetup?: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectItem {
|
||||
@@ -148,12 +146,9 @@ const TextInput: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome }) => {
|
||||
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome, quickSetup }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
const providers = useValidProviders()
|
||||
|
||||
const [step, setStep] = useState<AuthStep>("menu")
|
||||
const [step, setStep] = useState<AuthStep>(quickSetup ? "saving" : "menu")
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(
|
||||
StateManager.get().getApiConfiguration().actModeApiProvider ||
|
||||
StateManager.get().getApiConfiguration().planModeApiProvider ||
|
||||
@@ -163,6 +158,7 @@ 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)
|
||||
@@ -170,35 +166,15 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
|
||||
// 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")
|
||||
// Use providers.json order, filtered to only available providers
|
||||
const sortedProviders = useMemo(() => {
|
||||
const availableProviders = new Set(API_PROVIDERS_LIST)
|
||||
return getProviderOrder().filter((p) => availableProviders.has(p))
|
||||
}, [])
|
||||
|
||||
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" }]
|
||||
const items: SelectItem[] = [{ label: "Sign in with Cline account", value: "cline_auth" }]
|
||||
|
||||
// Add OpenAI Codex option for ChatGPT subscribers
|
||||
items.push({ label: "Sign in with ChatGPT Subscription", value: "openai_codex_auth" })
|
||||
@@ -221,13 +197,15 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const providerItems: SelectItem[] = useMemo(() => {
|
||||
const search = providerSearch.toLowerCase()
|
||||
const filtered = providerSearch
|
||||
? providers.filter((p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search))
|
||||
: providers
|
||||
? sortedProviders.filter(
|
||||
(p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search),
|
||||
)
|
||||
: sortedProviders
|
||||
return filtered.map((p: string) => ({
|
||||
label: getProviderLabel(p),
|
||||
value: p,
|
||||
}))
|
||||
}, [providers, providerSearch])
|
||||
}, [sortedProviders, providerSearch])
|
||||
|
||||
// Use shared scrollable list hook for provider windowing
|
||||
const TOTAL_PROVIDER_ROWS = 8
|
||||
@@ -259,6 +237,13 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}
|
||||
}, [step, selectedProvider])
|
||||
|
||||
// Handle quick setup
|
||||
useEffect(() => {
|
||||
if (quickSetup && step === "saving") {
|
||||
handleQuickSetup()
|
||||
}
|
||||
}, [quickSetup, step])
|
||||
|
||||
// Subscribe to auth status updates when in cline_auth step
|
||||
useEffect(() => {
|
||||
if (step !== "cline_auth") {
|
||||
@@ -274,10 +259,21 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
// Auth succeeded - save configuration and transition to model selection
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
// Auth succeeded - save configuration and transition to success
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "cline",
|
||||
[providerKey]: "cline",
|
||||
[modelIdKey]: openRouterDefaultModelId,
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("cline")
|
||||
setModelId(openRouterDefaultModelId)
|
||||
setModelId(config[modelIdKey])
|
||||
setStep("cline_model")
|
||||
}
|
||||
}
|
||||
@@ -291,6 +287,80 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}
|
||||
}, [step, controller])
|
||||
|
||||
const handleQuickSetup = async () => {
|
||||
if (!quickSetup) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { provider, apikey, modelid, baseurl } = quickSetup
|
||||
|
||||
// Validate required parameters
|
||||
if (!provider || !apikey || !modelid) {
|
||||
setErrorMessage("Quick setup requires --provider, --apikey, and --modelid flags")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedProvider = provider.toLowerCase().trim()
|
||||
|
||||
if (!sortedProviders.includes(normalizedProvider)) {
|
||||
setErrorMessage(`Invalid provider '${provider}'. Supported providers: ${sortedProviders.join(", ")}`)
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedProvider === "bedrock") {
|
||||
setErrorMessage(
|
||||
"Bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup.",
|
||||
)
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
|
||||
setErrorMessage("Base URL is only supported for OpenAI and OpenAI-compatible providers")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: normalizedProvider,
|
||||
planModeApiProvider: normalizedProvider,
|
||||
actModeApiModelId: modelid,
|
||||
planModeApiModelId: modelid,
|
||||
}
|
||||
|
||||
// Use provider-specific API key field
|
||||
const keyField = ProviderToApiKeyMap[normalizedProvider]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
// Set the first key field for the provider
|
||||
config[fields[0]] = apikey
|
||||
} else {
|
||||
// Fallback to generic apiKey
|
||||
config.apiKey = apikey
|
||||
}
|
||||
|
||||
if (baseurl) {
|
||||
config.openAiBaseUrl = baseurl
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
|
||||
await stateManager.flushPendingState()
|
||||
setSelectedProvider(normalizedProvider)
|
||||
setModelId(modelid)
|
||||
setBaseUrl(baseurl || "")
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}
|
||||
|
||||
// Start OpenAI Codex OAuth flow
|
||||
const startOpenAiCodexAuth = useCallback(async () => {
|
||||
try {
|
||||
@@ -304,10 +374,19 @@ 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()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "openai-codex",
|
||||
planModeApiProvider: "openai-codex",
|
||||
[providerKey]: "openai-codex",
|
||||
[modelIdKey]: openAiCodexDefaultModelId,
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("openai-codex")
|
||||
setModelId(openAiCodexDefaultModelId)
|
||||
setStep("success")
|
||||
@@ -322,6 +401,7 @@ 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))
|
||||
@@ -329,11 +409,6 @@ 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,9 +435,8 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "oca") {
|
||||
// Show employee check screen before starting auth
|
||||
setStep("oca_employee_check")
|
||||
if (value === "cline") {
|
||||
startClineAuth()
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
@@ -372,7 +446,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setStep("apikey")
|
||||
}
|
||||
},
|
||||
[startOcaAuth, startOpenAiCodexAuth],
|
||||
[startClineAuth, startOpenAiCodexAuth],
|
||||
)
|
||||
|
||||
const handleApiKeySubmit = useCallback(
|
||||
@@ -392,24 +466,39 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
if (selectedProvider === "bedrock" && bedrockConfig) {
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: model,
|
||||
controller,
|
||||
})
|
||||
} else {
|
||||
await applyProviderConfig({
|
||||
providerId: selectedProvider,
|
||||
apiKey,
|
||||
modelId: model,
|
||||
baseUrl: base,
|
||||
controller,
|
||||
})
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedProvider,
|
||||
planModeApiProvider: selectedProvider,
|
||||
actModeApiModelId: model,
|
||||
planModeApiModelId: model,
|
||||
apiProvider: selectedProvider,
|
||||
}
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
if (base) {
|
||||
config.openAiBaseUrl = base
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
@@ -418,7 +507,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[selectedProvider, apiKey, bedrockConfig, controller],
|
||||
[selectedProvider, apiKey, bedrockConfig],
|
||||
)
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
@@ -470,18 +559,11 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}, [])
|
||||
|
||||
// Auto-navigate to welcome after success (immediate)
|
||||
// For quick setup mode (no onNavigateToWelcome), exit the Ink app
|
||||
useEffect(() => {
|
||||
if (step === "success") {
|
||||
if (onNavigateToWelcome) {
|
||||
onNavigateToWelcome()
|
||||
} else {
|
||||
// Quick setup mode - exit Ink app after successful configuration
|
||||
// The cleanup handler in runInkApp will handle process exit
|
||||
exit()
|
||||
}
|
||||
if (step === "success" && onNavigateToWelcome) {
|
||||
onNavigateToWelcome()
|
||||
}
|
||||
}, [step, onNavigateToWelcome, exit])
|
||||
}, [step, onNavigateToWelcome])
|
||||
|
||||
// Error screen menu items
|
||||
const errorMenuItems: SelectItem[] = useMemo(() => {
|
||||
@@ -538,12 +620,6 @@ 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
|
||||
@@ -682,10 +758,6 @@ 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">
|
||||
@@ -720,11 +792,50 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
)
|
||||
|
||||
case "cline_model": {
|
||||
const allModels = featuredModels
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Choose a model</Text>
|
||||
<Text> </Text>
|
||||
<FeaturedModelPicker selectedIndex={clineModelIndex} />
|
||||
|
||||
{/* Model list */}
|
||||
{allModels.map((model, i) => (
|
||||
<Box flexDirection="column" key={model.id} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={i === clineModelIndex ? COLORS.primaryBlue : undefined}>
|
||||
{i === clineModelIndex ? "❯ " : " "}
|
||||
</Text>
|
||||
<Text bold color={i === clineModelIndex ? COLORS.primaryBlue : "white"}>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.label && (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={model.label === "FREE" ? "gray" : COLORS.primaryBlue}
|
||||
color="black">
|
||||
{" "}
|
||||
{model.label}{" "}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{model.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Browse all option */}
|
||||
<Box>
|
||||
<Text color={clineModelIndex === allModels.length ? COLORS.primaryBlue : "gray"}>
|
||||
{clineModelIndex === allModels.length ? "❯ " : " "}
|
||||
Browse all models...
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -770,13 +881,11 @@ 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",
|
||||
@@ -814,20 +923,20 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setProviderSearch((prev) => prev + input)
|
||||
}
|
||||
} else if (step === "cline_model") {
|
||||
const maxIndex = getFeaturedModelMaxIndex()
|
||||
const allModels = featuredModels
|
||||
const maxIndex = allModels.length // includes "Browse all" option
|
||||
|
||||
if (key.upArrow) {
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (isBrowseAllSelected(clineModelIndex)) {
|
||||
if (clineModelIndex === allModels.length) {
|
||||
// "Browse all models" selected
|
||||
setStep("modelid")
|
||||
} else {
|
||||
const selectedModel = getFeaturedModelAtIndex(clineModelIndex)
|
||||
if (selectedModel) {
|
||||
handleClineModelSelect(selectedModel.id)
|
||||
}
|
||||
// Featured model selected
|
||||
handleClineModelSelect(allModels[clineModelIndex].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -864,12 +973,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
<Text> </Text>
|
||||
{mainMenuItems.map((item, index) => (
|
||||
<Box key={item.value}>
|
||||
<Text>
|
||||
<Text color={index === menuIndex ? COLORS.primaryBlue : undefined}>
|
||||
{index === menuIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.value === "cline_auth" && <Text color="yellow"> (try Opus 4.6!)</Text>}
|
||||
<Text color={index === menuIndex ? COLORS.primaryBlue : undefined}>
|
||||
{index === menuIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
@@ -8,82 +8,40 @@
|
||||
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type { ClineAskUseMcpServer, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { 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.
|
||||
* Case-insensitive, avoids double-adding if already present.
|
||||
* 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, 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)
|
||||
const matches = text.match(actModeRegex)
|
||||
|
||||
if (!matches || parts.length <= 1) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
const nodes: React.ReactNode[] = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) {
|
||||
nodes.push(part)
|
||||
}
|
||||
if (matches[i]) {
|
||||
nodes.push(
|
||||
<React.Fragment key={`${keyPrefix}-act-mode-${i}`}>
|
||||
{matches[i]}
|
||||
<Text color="gray"> (Tab)</Text>
|
||||
</React.Fragment>,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Render inline markdown: **bold**, *italic*, `code`
|
||||
* Also adds "(Tab)" hints after "Act mode" mentions.
|
||||
* Returns array of React nodes with appropriate styling
|
||||
*/
|
||||
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
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before match (with Act Mode hint processing)
|
||||
// Add text before match
|
||||
if (match.index > lastIndex) {
|
||||
const beforeText = text.slice(lastIndex, match.index)
|
||||
nodes.push(...addHintedText(beforeText))
|
||||
nodes.push(text.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
const fullMatch = match[0]
|
||||
const key = `md-${match.index}`
|
||||
|
||||
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
|
||||
// Bold - also process for Act Mode hints inside bold text
|
||||
const boldContent = fullMatch.slice(2, -2)
|
||||
const hintedContent = addHintedText(boldContent)
|
||||
// Bold
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{hintedContent}
|
||||
{fullMatch.slice(2, -2)}
|
||||
</Text>,
|
||||
)
|
||||
} else if (fullMatch.startsWith("*") && fullMatch.endsWith("*")) {
|
||||
@@ -101,12 +59,12 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
// Add remaining text (with Act Mode hint processing)
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(...addHintedText(text.slice(lastIndex)))
|
||||
nodes.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes.length > 0 ? nodes : addHintedText(text)
|
||||
return nodes.length > 0 ? nodes : [text]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,20 +88,10 @@ 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; flashing?: boolean }> = ({
|
||||
children,
|
||||
color,
|
||||
flashing = false,
|
||||
}) => (
|
||||
const DotRow: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2}>
|
||||
{flashing ? (
|
||||
<Text color={color}>
|
||||
<Spinner type="toggle8" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>⏺</Text>
|
||||
)}
|
||||
<Text color={color}>⏺</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
@@ -166,11 +114,6 @@ const ResultRow: React.FC<{ children: React.ReactNode; isFirst?: boolean }> = ({
|
||||
* Get the primary argument to display for a tool (file path, command, url, etc.)
|
||||
*/
|
||||
function getToolMainArg(_toolName: string, args: Record<string, unknown>): string {
|
||||
// Search files: show 'regex' in path
|
||||
if (typeof args.regex === "string" && typeof args.path === "string") {
|
||||
return `'${args.regex}' in ${args.path}`
|
||||
}
|
||||
|
||||
// File path
|
||||
if (typeof args.path === "string") return args.path
|
||||
if (typeof args.file_path === "string") return args.file_path
|
||||
@@ -180,6 +123,9 @@ function getToolMainArg(_toolName: string, args: Record<string, unknown>): strin
|
||||
return args.command.length > 120 ? args.command.substring(0, 117) + "..." : args.command
|
||||
}
|
||||
|
||||
// Search regex
|
||||
if (typeof args.regex === "string") return args.regex
|
||||
|
||||
// URL
|
||||
if (typeof args.url === "string") return args.url
|
||||
|
||||
@@ -227,7 +173,7 @@ function truncate(text: string, maxLength: number): string {
|
||||
/**
|
||||
* Format tool result for display
|
||||
*/
|
||||
function formatToolResult(result: string, maxLines = 5): string[] {
|
||||
function formatToolResult(result: string, maxLines: number = 5): string[] {
|
||||
const lines = result.split("\n")
|
||||
if (lines.length <= maxLines) {
|
||||
return lines
|
||||
@@ -237,8 +183,8 @@ function formatToolResult(result: string, maxLines = 5): string[] {
|
||||
return displayLines
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStreaming }) => {
|
||||
const { type, ask, say, text, partial } = message
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
const { type, ask, say, text } = message
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
|
||||
@@ -276,7 +222,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow>
|
||||
<MarkdownText>{text}</MarkdownText>
|
||||
<Text>{text}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
@@ -294,26 +240,27 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
if (isFileEditTool(toolInfo.toolName) && filePath && toolInfo.args.content) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<DotRow color={toolColor}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<DiffView content={toolInfo.args.content as string} filePath={filePath as string | undefined} />
|
||||
<DiffView content={toolInfo.args.content} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Show result content for completed tools (both say and ask), or file path for pending asks
|
||||
const contentLines = toolInfo.result?.trim()
|
||||
? formatToolResult(toolInfo.result, 5)
|
||||
: (isToolAsk || isToolSay) && filePath
|
||||
? [filePath as string]
|
||||
: []
|
||||
// Show result content for completed tools, or file path for pending asks
|
||||
const contentLines =
|
||||
isToolSay && toolInfo.result?.trim()
|
||||
? formatToolResult(toolInfo.result, 5)
|
||||
: isToolAsk && filePath
|
||||
? [filePath as string]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<DotRow color={toolColor}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
{contentLines.length > 0 && (
|
||||
@@ -332,7 +279,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
if (isToolSay) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<DotRow color={toolColor}>
|
||||
<Text color={toolColor}>{truncate(text, 100)}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
@@ -354,7 +301,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{label}</Text>
|
||||
<Text>{truncate(command, 120)}</Text>
|
||||
@@ -389,102 +336,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
)
|
||||
}
|
||||
|
||||
// MCP approval (ask) or acknowledgment (say)
|
||||
if ((type === "ask" && ask === "use_mcp_server") || say === "use_mcp_server") {
|
||||
const isAsk = type === "ask"
|
||||
const parsed = text
|
||||
? jsonParseSafe<Partial<ClineAskUseMcpServer> & { serverName: string }>(text, {
|
||||
type: undefined,
|
||||
serverName: "unknown server",
|
||||
toolName: undefined,
|
||||
arguments: undefined,
|
||||
uri: undefined,
|
||||
})
|
||||
: undefined
|
||||
|
||||
const serverName = parsed?.serverName || "unknown server"
|
||||
const actionLabel = isAsk ? "Cline wants to use MCP" : "Cline used MCP"
|
||||
const targetLine =
|
||||
parsed?.type === "access_mcp_resource"
|
||||
? `resource: ${parsed?.uri || "unknown"}`
|
||||
: parsed?.type === "use_mcp_tool"
|
||||
? `tool: ${parsed?.toolName || "unknown"}`
|
||||
: "tool: unknown"
|
||||
|
||||
let argsLines: string[] = []
|
||||
if (parsed?.arguments && parsed.arguments.trim() && parsed.arguments !== "{}") {
|
||||
let formattedArgs = parsed.arguments
|
||||
try {
|
||||
formattedArgs = JSON.stringify(JSON.parse(parsed.arguments), null, 2)
|
||||
} catch {
|
||||
// Keep raw string if not valid JSON
|
||||
}
|
||||
argsLines = formatToolResult(formattedArgs, 10)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{actionLabel}</Text>
|
||||
<Text>{`: ${serverName}`}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
<ResultRow isFirst>
|
||||
<Text color="gray">{targetLine}</Text>
|
||||
</ResultRow>
|
||||
{argsLines.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={3} width="100%">
|
||||
<Text color="gray">args:</Text>
|
||||
{argsLines.map((line, idx) => (
|
||||
<Text color="gray" key={`mcp-args-${idx}`}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>MCP response</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{lines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Error messages
|
||||
if (say === "clineignore_error") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text color="red" wrap="wrap">
|
||||
Cline tried to access <Text bold>{text}</Text> which is blocked by the .clineignore file.
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (say === "error" || (type === "ask" && ask === "api_req_failed")) {
|
||||
// Try to parse error message if it's JSON
|
||||
let errorMessage = text || "Unknown error"
|
||||
@@ -599,7 +451,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
if (say === "browser_action" || say === "browser_action_launch") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline used the browser</Text>
|
||||
{text && (
|
||||
@@ -618,7 +470,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
if (say === "mcp_server_request_started") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline is using an MCP tool</Text>
|
||||
{text && (
|
||||
@@ -633,20 +485,6 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
)
|
||||
}
|
||||
|
||||
// MCP notifications
|
||||
if (say === "mcp_notification" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>MCP Notification</Text>
|
||||
<Text>: {truncate(text, 120)}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Info messages
|
||||
if (say === "info") {
|
||||
return (
|
||||
@@ -688,17 +526,6 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
}
|
||||
}
|
||||
|
||||
// Act mode response (non-blocking progress update)
|
||||
if (type === "ask" && ask === "act_mode_respond" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<MarkdownText color={toolColor}>{text}</MarkdownText>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Plan mode response
|
||||
if (type === "ask" && ask === "plan_mode_respond" && text) {
|
||||
const parsed = jsonParseSafe(text, { response: undefined as string | undefined })
|
||||
@@ -713,19 +540,6 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
}
|
||||
}
|
||||
|
||||
// Mistake limit reached (ask)
|
||||
if (type === "ask" && ask === "mistake_limit_reached") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text color="red" wrap="wrap">
|
||||
<Text bold>Error</Text>: {text || "Mistake limit reached."}
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// New task request from assistant
|
||||
if (type === "ask" && ask === "new_task" && text) {
|
||||
return (
|
||||
@@ -742,54 +556,6 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
|
||||
)
|
||||
}
|
||||
|
||||
// Condense conversation request
|
||||
if (type === "ask" && ask === "condense" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to condense your conversation:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Summarize task request
|
||||
if (type === "ask" && ask === "summarize_task" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to summarize the task:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Report bug request
|
||||
if (type === "ask" && ask === "report_bug" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to create a Github issue:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Skip other message types
|
||||
return null
|
||||
}
|
||||
@@ -807,8 +573,6 @@ 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 shutdown event or other means),
|
||||
* These tests verify that when the user exits (via Ctrl+C or other means),
|
||||
* the input field is properly hidden before the app terminates.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -174,21 +175,12 @@ 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
|
||||
|
||||
@@ -221,6 +213,33 @@ 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} />)
|
||||
@@ -230,59 +249,39 @@ describe("ChatView Exit and Cleanup", () => {
|
||||
expect(shutdownMockState.listeners.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should hide input when shutdown event fires", async () => {
|
||||
it("should hide UI 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 shutdown event when onExit prop is undefined", async () => {
|
||||
const { lastFrame } = render(<ChatView />)
|
||||
it("should handle exit when onExit prop is undefined", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView />)
|
||||
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
stdin.write("\x03")
|
||||
await delay()
|
||||
|
||||
// Should not throw, UI should still hide
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should handle multiple shutdown events gracefully", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
it("should handle multiple Ctrl+C presses gracefully", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Fire multiple shutdown events
|
||||
shutdownMockState.fire()
|
||||
shutdownMockState.fire()
|
||||
shutdownMockState.fire()
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
|
||||
await delay()
|
||||
|
||||
// UI should still hide properly
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -295,15 +294,14 @@ describe("ChatView UI State During Exit", () => {
|
||||
|
||||
it("should preserve static content and footer, only hide input during exit", async () => {
|
||||
const onExit = createExitMock()
|
||||
const { lastFrame } = render(<ChatView onExit={onExit} />)
|
||||
const { lastFrame, stdin } = 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:")
|
||||
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
stdin.write("\x03")
|
||||
await delay()
|
||||
|
||||
const frameAfter = lastFrame()
|
||||
@@ -101,19 +101,16 @@
|
||||
* - log-update: node_modules/ink/build/log-update.js (eraseLines logic)
|
||||
*/
|
||||
|
||||
import type { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import type { 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 { 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"
|
||||
@@ -123,9 +120,7 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useHomeEndKeys } from "../hooks/useHomeEndKeys"
|
||||
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import { findWordEnd, findWordStart, useTextInput } from "../hooks/useTextInput"
|
||||
import { moveCursorDown, moveCursorUp } from "../utils/cursor"
|
||||
import { setTerminalTitle } from "../utils/display"
|
||||
import {
|
||||
@@ -138,44 +133,26 @@ 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"
|
||||
import { AsciiMotionCli, StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { ChatMessage } from "./ChatMessage"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
import { HelpPanelContent } from "./HelpPanelContent"
|
||||
import { HighlightedInput } from "./HighlightedInput"
|
||||
import { HistoryPanelContent } from "./HistoryPanelContent"
|
||||
import { providerModels } from "./ModelPicker"
|
||||
import { SessionSummary } from "./SessionSummary"
|
||||
import { SettingsPanelContent } from "./SettingsPanelContent"
|
||||
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
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
robotTopRow?: number
|
||||
initialPrompt?: string
|
||||
initialImages?: string[]
|
||||
taskId?: string
|
||||
@@ -229,9 +206,9 @@ function getGitDiffStats(cwd?: string): GitDiffStats | null {
|
||||
const delMatch = output.match(/(\d+) deletion/)
|
||||
|
||||
return {
|
||||
files: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
|
||||
additions: addMatch ? Number.parseInt(addMatch[1], 10) : 0,
|
||||
deletions: delMatch ? Number.parseInt(delMatch[1], 10) : 0,
|
||||
files: filesMatch ? parseInt(filesMatch[1], 10) : 0,
|
||||
additions: addMatch ? parseInt(addMatch[1], 10) : 0,
|
||||
deletions: delMatch ? parseInt(delMatch[1], 10) : 0,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -242,7 +219,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 = 8): { filled: string; empty: string } {
|
||||
function createContextBar(used: number, total: number, width: number = 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
|
||||
@@ -332,7 +309,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(Number.parseInt(num, 10))
|
||||
const content = pastedTexts.get(parseInt(num, 10))
|
||||
return content ?? match
|
||||
})
|
||||
}
|
||||
@@ -342,6 +319,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
onExit,
|
||||
onComplete: _onComplete,
|
||||
onError,
|
||||
robotTopRow,
|
||||
initialPrompt,
|
||||
initialImages,
|
||||
taskId,
|
||||
@@ -357,27 +335,9 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Prefer prop controller over context controller (memoized for stable reference in callbacks)
|
||||
const ctrl = useMemo(() => controller || taskController, [controller, taskController])
|
||||
|
||||
// Input state - using hook for text editing with keyboard shortcuts
|
||||
const {
|
||||
text: textInput,
|
||||
cursorPos,
|
||||
setText: setTextInput,
|
||||
setCursorPos,
|
||||
handleKeyboardSequence,
|
||||
handleCtrlShortcut,
|
||||
deleteCharBefore,
|
||||
insertText: insertTextAtCursor,
|
||||
} = useTextInput()
|
||||
|
||||
// Get storage key for persisting input across remounts
|
||||
const storageKey = useMemo(() => getInputStorageKey(ctrl, taskId), [ctrl, taskId])
|
||||
|
||||
// Refs for text input and cursor position (used by useHomeEndKeys and to avoid stale closures in useInput)
|
||||
const textInputRef = useRef(textInput)
|
||||
textInputRef.current = textInput
|
||||
const cursorPosRef = useRef(cursorPos)
|
||||
cursorPosRef.current = cursorPos
|
||||
|
||||
// Input state
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [cursorPos, setCursorPos] = useState(0)
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0) // For file menu
|
||||
const [historyIndex, setHistoryIndex] = useState(-1) // -1 = not browsing history, 0+ = history item index
|
||||
@@ -388,10 +348,8 @@ 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>>(() => {
|
||||
return inputStateStorage.get(storageKey)?.pastedTexts ?? new Map()
|
||||
})
|
||||
const pasteCounterRef = useRef<number>(inputStateStorage.get(storageKey)?.pasteCounter ?? 0)
|
||||
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(new Map())
|
||||
const pasteCounterRef = useRef(0)
|
||||
// Track paste timing to combine chunks that arrive in rapid succession
|
||||
const lastPasteTimeRef = useRef<number>(0)
|
||||
const activePasteNumRef = useRef<number>(0)
|
||||
@@ -407,47 +365,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
|
||||
const lastSlashIndexRef = useRef<number>(-1)
|
||||
|
||||
// Panel state
|
||||
// Settings panel state
|
||||
const [activePanel, setActivePanel] = useState<
|
||||
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
|
||||
| { type: "history" }
|
||||
| { type: "help" }
|
||||
| null
|
||||
{ type: "settings"; initialMode?: "model-picker" } | { type: "history" } | null
|
||||
>(null)
|
||||
|
||||
// Handle Home/End keys from raw stdin (Ink doesn't expose these in useInput)
|
||||
useHomeEndKeys({
|
||||
onHome: useCallback(() => setCursorPos(0), [setCursorPos]),
|
||||
onEnd: useCallback(() => setCursorPos(textInputRef.current.length), [setCursorPos]),
|
||||
isActive: !activePanel, // Only active when no panel is open
|
||||
})
|
||||
|
||||
// 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.
|
||||
@@ -474,10 +399,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const [yolo, _setYolo] = useState<boolean>(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled") ?? false)
|
||||
const [autoApproveAll, setAutoApproveAll] = useState<boolean>(
|
||||
() => StateManager.get().getGlobalSettingsKey("autoApproveAllToggled") ?? false,
|
||||
)
|
||||
const [yolo, setYolo] = useState<boolean>(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled") ?? false)
|
||||
|
||||
// Sync mode from core state updates (e.g. yolo auto-switching plan to act)
|
||||
useEffect(() => {
|
||||
@@ -486,30 +408,27 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}, [taskState.mode])
|
||||
|
||||
const toggleAutoApproveAll = useCallback(() => {
|
||||
const newValue = !autoApproveAll
|
||||
setAutoApproveAll(newValue)
|
||||
StateManager.get().setGlobalState("autoApproveAllToggled", newValue)
|
||||
}, [autoApproveAll])
|
||||
const toggleYolo = useCallback(() => {
|
||||
const newValue = !yolo
|
||||
setYolo(newValue)
|
||||
StateManager.get().setGlobalState("yoloModeToggled", newValue)
|
||||
}, [yolo])
|
||||
|
||||
// Get provider based on current mode (computed first since modelId depends on it)
|
||||
// Get model ID based on current mode
|
||||
// Re-read when activePanel changes (settings panel closes) to pick up changes
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
|
||||
}, [mode, activePanel])
|
||||
|
||||
// Get provider based on current mode
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
return (stateManager.getGlobalSettingsKey(providerKey) as string) || ""
|
||||
return (stateManager.getGlobalSettingsKey(providerKey) as string) || "anthropic"
|
||||
}, [mode, activePanel])
|
||||
|
||||
// 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) || getProviderDefaultModelId(provider as ApiProvider) || ""
|
||||
}, [mode, provider, activePanel])
|
||||
|
||||
const toggleMode = useCallback(async () => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
@@ -525,29 +444,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}, [mode, ctrl, textInput, pastedTexts])
|
||||
|
||||
// Clear the terminal view and reset task state (used by /clear and "Start New Task" button)
|
||||
// This is async to ensure clearTask() completes before we remount, preventing race conditions
|
||||
// where the old messages get fetched and restored before the controller clears them.
|
||||
const clearViewAndResetTask = useCallback(async () => {
|
||||
// First, clear the task on the controller and wait for it to finish
|
||||
// This ensures the controller has no messages before we remount
|
||||
if (ctrl) {
|
||||
await ctrl.clearTask()
|
||||
}
|
||||
|
||||
// Now clear the terminal and reset React state
|
||||
const clearViewAndResetTask = useCallback(() => {
|
||||
process.stdout.write("\x1b[2J\x1b[3J\x1b[H") // Clear screen + scrollback, cursor home
|
||||
setTaskSwitchKey((k) => k + 1) // Force remount for fresh Static instance
|
||||
clearState() // Force clear React state (bypasses empty messages check)
|
||||
if (ctrl) {
|
||||
ctrl.clearTask().then(() => ctrl.postStateToWebview())
|
||||
}
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
// Clear persisted state
|
||||
inputStateStorage.delete(storageKey)
|
||||
|
||||
// Post the now-empty state
|
||||
if (ctrl) {
|
||||
ctrl.postStateToWebview()
|
||||
}
|
||||
}, [ctrl, clearState, storageKey])
|
||||
}, [ctrl, clearState])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
@@ -619,7 +525,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
|
||||
setAvailableCommands(sortCommandsWorkflowsFirst([...cliCommands, ...cliOnlyCommands]))
|
||||
} catch {
|
||||
// Fallback: commands will be empty, menu won't show
|
||||
}
|
||||
@@ -654,14 +560,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (m.say === "checkpoint_created") return false
|
||||
if (m.say === "api_req_started") return false
|
||||
if (m.say === "api_req_retried") return false // Redundant with error_retry messages
|
||||
if (m.say === "reasoning") return false // Hide thinking traces - they clutter the UI
|
||||
return true
|
||||
})
|
||||
|
||||
// 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)
|
||||
// Combine command messages with their output (like webview does)
|
||||
return combineCommandSequences(filtered)
|
||||
}, [messages])
|
||||
|
||||
// Detect task switches by watching first message timestamp change.
|
||||
@@ -808,8 +711,6 @@ 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)
|
||||
@@ -817,7 +718,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[ctrl, pendingAsk, pastedTexts, storageKey],
|
||||
[ctrl, pendingAsk, pastedTexts],
|
||||
)
|
||||
|
||||
// Handle cancel/interrupt
|
||||
@@ -860,13 +761,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
sendAskResponse("yesButtonClicked")
|
||||
break
|
||||
case "reject":
|
||||
// Check for states that should trigger exit (all end-of-task states with Exit button)
|
||||
if (
|
||||
pendingAsk?.ask === "resume_task" ||
|
||||
pendingAsk?.ask === "resume_completed_task" ||
|
||||
pendingAsk?.ask === "completion_result" ||
|
||||
pendingAsk?.ask === "new_task"
|
||||
) {
|
||||
// Check for resume states that should trigger exit
|
||||
if (pendingAsk?.ask === "resume_task" || pendingAsk?.ask === "resume_completed_task") {
|
||||
handleExit()
|
||||
} else {
|
||||
sendAskResponse("noButtonClicked")
|
||||
@@ -885,7 +781,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
await ctrl.initTask(pendingAsk.text || "")
|
||||
} else {
|
||||
// From completion_result or resume_completed_task - full clear
|
||||
await clearViewAndResetTask()
|
||||
clearViewAndResetTask()
|
||||
}
|
||||
break
|
||||
case "cancel":
|
||||
@@ -893,7 +789,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
break
|
||||
}
|
||||
},
|
||||
[controller, taskController, sendAskResponse, pendingAsk, handleExit, handleCancel, clearViewAndResetTask],
|
||||
[controller, taskController, sendAskResponse, pendingAsk, handleExit, handleCancel],
|
||||
)
|
||||
|
||||
// Handle task submission (new task)
|
||||
@@ -908,8 +804,6 @@ 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
|
||||
@@ -937,12 +831,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
onError?.()
|
||||
}
|
||||
},
|
||||
[ctrl, onError, pastedTexts, storageKey],
|
||||
[ctrl, onError, pastedTexts],
|
||||
)
|
||||
|
||||
// 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)) {
|
||||
@@ -963,32 +855,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (initialPrompt) {
|
||||
setTerminalTitle(initialPrompt)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
// initialImages are already data URLs from index.ts processing
|
||||
await ctrl.initTask(initialPrompt || "", initialImages && initialImages.length > 0 ? initialImages : undefined)
|
||||
} catch (_error) {
|
||||
onError?.()
|
||||
}
|
||||
@@ -1052,48 +920,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
// Handle keyboard input
|
||||
//
|
||||
// KEYBOARD PRIORITY ORDER (first match wins):
|
||||
// 1. Mouse escape sequences -> filtered out (from AsciiMotionCli tracking)
|
||||
// 2. Option+arrow escape sequences -> word navigation (handleKeyboardSequence)
|
||||
// 3. Option+arrow via key.meta -> word navigation (backup for when Ink parses it)
|
||||
// 4. Panel open -> bail (let panel handle its own input)
|
||||
// 5. Slash menu open -> menu navigation (up/down/tab/return/escape)
|
||||
// 6. File menu open -> menu navigation (up/down/tab/return/escape)
|
||||
// 7. History navigation -> up/down when input empty or matches history item
|
||||
// 8. Button actions -> "1"/"2" keys when buttons shown and no text typed
|
||||
// 9. Ask responses -> return to send, numbers for option selection
|
||||
// 10. Ctrl shortcuts -> Ctrl+A/E/W/U (handleCtrlShortcut)
|
||||
// 11. Large paste detection -> collapse into placeholder
|
||||
// 12. Normal input -> tab (mode toggle), return (submit), backspace, arrows, text
|
||||
//
|
||||
// Note: Home/End keys are handled separately by useHomeEndKeys hook because
|
||||
// Ink doesn't expose them in useInput (it sets input='' for these keys).
|
||||
//
|
||||
useInput((input, key) => {
|
||||
// 1. Filter out mouse escape sequences from AsciiMotionCli's mouse tracking
|
||||
// Handle Ctrl+C - hide input and exit gracefully
|
||||
if (input === "\x03" || (key.ctrl && input === "c")) {
|
||||
handleExit()
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out mouse escape sequences from AsciiMotionCli's mouse tracking
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Handle Option+arrow escape sequences for word navigation
|
||||
if (handleKeyboardSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setCursorPos(findWordEnd(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. When a panel is open, let the panel handle its own input
|
||||
// When a panel is open, let the panel handle its own input
|
||||
if (activePanel) {
|
||||
return
|
||||
}
|
||||
@@ -1101,7 +940,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const inSlashMenu = slashInfo.inSlashMode && filteredCommands.length > 0 && !slashMenuDismissed
|
||||
const inFileMenu = mentionInfo.inMentionMode && fileResults.length > 0 && !inSlashMenu
|
||||
|
||||
// 5. Slash command menu navigation (takes priority over file menu)
|
||||
// Slash command menu navigation (takes priority over file menu)
|
||||
if (inSlashMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedSlashIndex((i) => Math.max(0, i - 1))
|
||||
@@ -1115,14 +954,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const cmd = filteredCommands[selectedSlashIndex]
|
||||
if (cmd) {
|
||||
// Handle CLI-only commands locally
|
||||
if (cmd.name === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
@@ -1132,16 +963,9 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return
|
||||
}
|
||||
if (cmd.name === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
// Use current mode's provider to determine picker type
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
// Set model for current mode (plan or act)
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
// If separate models for plan/act is enabled, just open settings (user picks which mode)
|
||||
const hasSeparateModels = StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
setActivePanel({ type: "settings", initialMode: hasSeparateModels ? undefined : "model-picker" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
@@ -1162,10 +986,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "exit") {
|
||||
handleExit()
|
||||
return
|
||||
}
|
||||
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
|
||||
setTextInput(newText)
|
||||
setCursorPos(newText.length)
|
||||
@@ -1181,7 +1001,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 6. File mention menu navigation
|
||||
// File mention menu navigation
|
||||
if (inFileMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
@@ -1209,7 +1029,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 7. History navigation with up/down arrows
|
||||
// History navigation with up/down arrows
|
||||
// Only works when: input is empty, or input matches the currently selected history item
|
||||
if (key.upArrow && !inSlashMenu && !inFileMenu) {
|
||||
const historyItems = getHistoryItems()
|
||||
@@ -1259,7 +1079,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Handle button actions (1 for primary, 2 for secondary)
|
||||
// Handle button actions (1 for primary, 2 for secondary)
|
||||
// Only when buttons are enabled, not streaming, and no text has been typed
|
||||
if (
|
||||
buttonConfig.enableButtons &&
|
||||
@@ -1274,8 +1094,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (hasPrimary && buttonConfig.primaryAction) {
|
||||
handleButtonAction(buttonConfig.primaryAction, true)
|
||||
return
|
||||
}
|
||||
if (hasSecondary && !hasPrimary && buttonConfig.secondaryAction) {
|
||||
} else if (hasSecondary && !hasPrimary && buttonConfig.secondaryAction) {
|
||||
handleButtonAction(buttonConfig.secondaryAction, false)
|
||||
return
|
||||
}
|
||||
@@ -1287,7 +1106,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Handle ask responses for options and text input
|
||||
// Handle ask responses for options and text input
|
||||
if (pendingAsk && !isYoloSuppressed(yolo, pendingAsk.ask as ClineAsk | undefined)) {
|
||||
// Allow sending text message for any ask type where sending is enabled
|
||||
if (key.return && textInput.trim() && !buttonConfig.sendingDisabled) {
|
||||
@@ -1296,7 +1115,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
// Number selection for options (only when no text typed yet)
|
||||
if (askType === "options") {
|
||||
const num = Number.parseInt(input, 10)
|
||||
const num = parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= askOptions.length) {
|
||||
const selectedOption = askOptions[num - 1]
|
||||
sendAskResponse("messageResponse", selectedOption)
|
||||
@@ -1305,12 +1124,40 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Handle Ctrl+ shortcuts (Ctrl+A, Ctrl+E, Ctrl+W, etc.)
|
||||
if (key.ctrl && input && handleCtrlShortcut(input)) {
|
||||
return
|
||||
// Handle Ctrl+ shortcuts
|
||||
const keydown = input?.toLowerCase()
|
||||
if (key.ctrl && cursorPos && keydown) {
|
||||
switch (keydown) {
|
||||
case "u": // Ctrl+U, clear line before cursor
|
||||
if (cursorPos > 0) {
|
||||
setTextInput((prev) => prev.slice(cursorPos))
|
||||
setCursorPos(0)
|
||||
}
|
||||
return
|
||||
case "e": // Ctrl+E, move cursor to end
|
||||
setCursorPos(textInput.length)
|
||||
return
|
||||
case "b": // Ctrl+B, move cursor left
|
||||
setCursorPos((pos) => Math.max(0, pos - 1))
|
||||
return
|
||||
case "f": // Ctrl+F, move cursor right
|
||||
setCursorPos((pos) => Math.min(textInput.length, pos + 1))
|
||||
return
|
||||
case "d": // Ctrl+D, delete character after cursor
|
||||
if (cursorPos < textInput.length) {
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + prev.slice(cursorPos + 1))
|
||||
}
|
||||
return
|
||||
case "h": // Ctrl+H, delete character before cursor (like backspace)
|
||||
if (cursorPos > 0) {
|
||||
setTextInput((prev) => prev.slice(0, cursorPos - 1) + prev.slice(cursorPos))
|
||||
setCursorPos((pos) => pos - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Detect paste by checking if input length exceeds threshold
|
||||
// Detect paste by checking if input length exceeds threshold
|
||||
// Large pastes mess up the terminal UI, so we collapse them into a placeholder
|
||||
// Terminal sends large pastes in multiple chunks, so we combine chunks that arrive rapidly
|
||||
if (input && input.length > PASTE_COLLAPSE_THRESHOLD) {
|
||||
@@ -1338,10 +1185,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
pasteUpdateTimeoutRef.current = setTimeout(() => {
|
||||
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
|
||||
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)
|
||||
setTextInput((prev) => {
|
||||
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
|
||||
return prev.replace(pattern, newPlaceholder)
|
||||
})
|
||||
// Update cursor to be right after the placeholder
|
||||
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
|
||||
Logger.info(`Paste #${pasteNum} complete: ${activePasteLinesRef.current} lines`)
|
||||
@@ -1354,8 +1201,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
pasteCounterRef.current += 1
|
||||
const pasteNum = pasteCounterRef.current
|
||||
activePasteNumRef.current = pasteNum
|
||||
const currentCursorPos = cursorPosRef.current // Use ref to avoid stale closure
|
||||
activePasteStartPosRef.current = currentCursorPos // Track where placeholder starts
|
||||
activePasteStartPosRef.current = cursorPos // 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
|
||||
@@ -1367,31 +1213,31 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return next
|
||||
})
|
||||
|
||||
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)
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + placeholder + prev.slice(cursorPos))
|
||||
setCursorPos(cursorPos + placeholder.length)
|
||||
return // Exit early - don't also add the raw input via normal handling below
|
||||
}
|
||||
|
||||
// 12. Normal input handling
|
||||
// Normal input handling
|
||||
if (key.shift && key.tab) {
|
||||
toggleAutoApproveAll()
|
||||
toggleYolo()
|
||||
return
|
||||
}
|
||||
if (key.tab && !mentionInfo.inMentionMode && !slashInfo.inSlashMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode && !slashInfo.inSlashMode && !pendingAsk && !isSpinnerActive) {
|
||||
if (key.return && !mentionInfo.inMentionMode && !slashInfo.inSlashMode && !pendingAsk) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
handleSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
deleteCharBefore()
|
||||
if (cursorPos > 0) {
|
||||
setTextInput((prev) => prev.slice(0, cursorPos - 1) + prev.slice(cursorPos))
|
||||
setCursorPos((pos) => pos - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Cursor movement (when not in a menu)
|
||||
@@ -1400,20 +1246,21 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return
|
||||
}
|
||||
if (key.rightArrow && !inSlashMenu && !inFileMenu) {
|
||||
setCursorPos((pos) => Math.min(textInputRef.current.length, pos + 1))
|
||||
setCursorPos((pos) => Math.min(textInput.length, pos + 1))
|
||||
return
|
||||
}
|
||||
if (key.upArrow && !inSlashMenu && !inFileMenu) {
|
||||
setCursorPos(moveCursorUp(textInputRef.current, cursorPosRef.current))
|
||||
setCursorPos(moveCursorUp(textInput, cursorPos))
|
||||
return
|
||||
}
|
||||
if (key.downArrow && !inSlashMenu && !inFileMenu) {
|
||||
setCursorPos(moveCursorDown(textInputRef.current, cursorPosRef.current))
|
||||
setCursorPos(moveCursorDown(textInput, cursorPos))
|
||||
return
|
||||
}
|
||||
// Normal input (single char or short paste)
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
insertTextAtCursor(input)
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + input + prev.slice(cursorPos))
|
||||
setCursorPos((pos) => pos + input.length)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1474,10 +1321,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 interacted */}
|
||||
{/* Animated robot and welcome text - only shown before messages start and user hasn't scrolled */}
|
||||
{isWelcomeState && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<AsciiMotionCli onInteraction={() => setUserScrolled(true)} />
|
||||
<AsciiMotionCli onScroll={() => setUserScrolled(true)} robotTopRow={robotTopRow} />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
@@ -1528,7 +1375,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
<SettingsPanelContent
|
||||
controller={ctrl}
|
||||
initialMode={activePanel.initialMode}
|
||||
initialModelKey={activePanel.initialModelKey}
|
||||
onClose={() => setActivePanel(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -1542,9 +1388,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Help panel */}
|
||||
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
|
||||
|
||||
{/* Slash command menu - below input (takes priority over file menu) */}
|
||||
{showSlashMenu && !activePanel && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
@@ -1584,7 +1427,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
{/* Row 1: Instructions (left, can wrap) | Plan/Act toggle (right, no wrap) */}
|
||||
<Box justifyContent="space-between" paddingLeft={1} paddingRight={1} width="100%">
|
||||
<Box flexShrink={1} flexWrap="wrap">
|
||||
<Text color="gray">/ for commands · @ for files</Text>
|
||||
<Text color="gray">@ for files · / for commands</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} gap={1}>
|
||||
<Box>
|
||||
@@ -1636,17 +1479,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
{/* Row 4: Auto-approve toggle */}
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
{autoApproveAll ? (
|
||||
<Text>
|
||||
<Text color="green">⏵⏵ Auto-approve all enabled</Text>
|
||||
<Text color="gray"> (Shift+Tab)</Text>
|
||||
</Text>
|
||||
{yolo ? (
|
||||
<Text color="green">⏵⏵ Auto-approve all enabled (Shift+Tab)</Text>
|
||||
) : (
|
||||
<Text color="gray">Auto-approve all disabled (Shift+Tab)</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Session summary - shown when exiting */}
|
||||
{isExiting && <SessionSummary />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
@@ -13,7 +13,6 @@ 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,
|
||||
@@ -22,8 +21,6 @@ import {
|
||||
HookInfo,
|
||||
HookRow,
|
||||
MAX_VISIBLE,
|
||||
ObjectEditorPanel,
|
||||
ObjectEditorState,
|
||||
parseValue,
|
||||
SEPARATOR,
|
||||
SectionHeader,
|
||||
@@ -108,8 +105,6 @@ 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(
|
||||
@@ -117,13 +112,6 @@ 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[] = []
|
||||
@@ -171,7 +159,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
const currentListLength = useMemo(() => {
|
||||
switch (currentTab) {
|
||||
case "settings":
|
||||
return filteredConfigEntries.length
|
||||
return configEntries.length
|
||||
case "rules":
|
||||
return ruleEntries.length
|
||||
case "workflows":
|
||||
@@ -183,14 +171,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}, [
|
||||
currentTab,
|
||||
filteredConfigEntries.length,
|
||||
ruleEntries.length,
|
||||
workflowEntries.length,
|
||||
hookEntries.length,
|
||||
skillEntries.length,
|
||||
])
|
||||
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
|
||||
|
||||
// Get available tabs
|
||||
const availableTabs = useMemo(() => {
|
||||
@@ -210,11 +191,10 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
setCurrentTab(newTab)
|
||||
setSelectedIndex(0)
|
||||
setIsEditing(false)
|
||||
setObjectEditor(null)
|
||||
}
|
||||
|
||||
// Settings tab handlers
|
||||
const selectedConfigEntry = filteredConfigEntries[selectedIndex]
|
||||
const selectedConfigEntry = configEntries[selectedIndex]
|
||||
|
||||
const handleSettingsSave = (value: string | boolean) => {
|
||||
if (!selectedConfigEntry) {
|
||||
@@ -230,43 +210,6 @@ 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
|
||||
@@ -297,22 +240,15 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
// Input handling
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (objectEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (input.toLowerCase() === "q" || key.escape) {
|
||||
exit()
|
||||
}
|
||||
|
||||
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
|
||||
// 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 (targetIdx >= 0 && targetIdx < availableTabs.length) {
|
||||
handleTabChange(availableTabs[targetIdx].key)
|
||||
}
|
||||
@@ -320,45 +256,21 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
}
|
||||
|
||||
// List navigation (arrow keys and vim-style j/k)
|
||||
if (key.upArrow) {
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
|
||||
} else if (key.downArrow) {
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
|
||||
}
|
||||
|
||||
// Tab-specific actions
|
||||
if (currentTab === "settings") {
|
||||
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
|
||||
}
|
||||
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
|
||||
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
|
||||
setIsEditing(true)
|
||||
} else if (key.ctrl && input.toLowerCase() === "r") {
|
||||
} else if (input === "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 || key.tab || input === " ") {
|
||||
} else if (key.return || input === " ") {
|
||||
// Toggle for rules/workflows/hooks/skills
|
||||
handleToggle()
|
||||
}
|
||||
@@ -426,31 +338,13 @@ 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 = filteredConfigEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
const visibleEntries = configEntries.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>
|
||||
@@ -613,12 +507,12 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
|
||||
// Help text based on current tab
|
||||
const getHelpText = () => {
|
||||
const base = "↑/↓ Navigate • ←/→ tabs • 1-5 tabs • Esc Exit"
|
||||
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
if (currentTab === "settings") {
|
||||
return `${base} • Type to search • Enter/Tab Edit (booleans toggle) • Backspace clear search • Ctrl+R Reset`
|
||||
return `${base} • Enter/e Edit • r Reset`
|
||||
}
|
||||
const openFolder = onOpenFolder ? " • o Open folder" : ""
|
||||
return `${base} • Enter/Tab/Space Toggle${openFolder}`
|
||||
return `${base} • Enter/Space Toggle${openFolder}`
|
||||
}
|
||||
|
||||
return (
|
||||
+11
-180
@@ -46,19 +46,16 @@ export interface SkillInfo {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ObjectEditorState {
|
||||
source: "global" | "workspace"
|
||||
key: string
|
||||
path: string[]
|
||||
value: Record<string, unknown>
|
||||
selectedIndex: number
|
||||
isEditingValue: boolean
|
||||
editValue: string
|
||||
}
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"subagentsEnabled",
|
||||
"subagentTerminalOutputLineLimit",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
])
|
||||
|
||||
export const EXCLUDED_KEYS = new Set(["taskHistory", "primaryRootIndex", "welcomeViewCompleted", "isNewUser"])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
|
||||
export const MAX_VISIBLE = 12
|
||||
export const SEPARATOR = "─".repeat(80)
|
||||
|
||||
@@ -138,7 +135,7 @@ export function parseValue(input: string, type: ValueType): unknown {
|
||||
return input.toLowerCase() === "true" || input === "1"
|
||||
}
|
||||
if (type === "number") {
|
||||
const num = Number.parseFloat(input)
|
||||
const num = parseFloat(input)
|
||||
return Number.isNaN(num) ? 0 : num
|
||||
}
|
||||
if (type === "object") {
|
||||
@@ -220,7 +217,7 @@ export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel,
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="white">{value}</Text>
|
||||
<Text color="cyan">|</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">Type: {type} • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
@@ -382,169 +379,3 @@ 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* DiffView component for displaying file diffs in Ink
|
||||
* Shows unified diff output with colored lines for additions/deletions
|
||||
* Supports SEARCH/REPLACE format and ApplyPatch format
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
|
||||
interface DiffViewProps {
|
||||
/** Diff content (SEARCH/REPLACE format, ApplyPatch format, or raw content for new files) */
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: "add" | "remove" | "context" | "separator"
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ParsedPatch {
|
||||
additions: number
|
||||
deletions: number
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
// Constants for format markers
|
||||
const MARKERS = {
|
||||
SEARCH_BLOCK: "------- SEARCH",
|
||||
SEARCH_SEPARATOR: "=======",
|
||||
REPLACE_BLOCK: "+++++++ REPLACE",
|
||||
NEW_BEGIN: "*** Begin Patch",
|
||||
NEW_END: "*** End Patch",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse SEARCH/REPLACE format into diff lines
|
||||
* Format: ------- SEARCH\n...\n=======\n...\n+++++++ REPLACE
|
||||
*/
|
||||
function parseSearchReplaceFormat(content: string): ParsedPatch {
|
||||
const result: ParsedPatch = { additions: 0, deletions: 0, lines: [] }
|
||||
|
||||
// Find all SEARCH blocks
|
||||
const searchRegex = /-{7,} SEARCH/g
|
||||
const searchPositions: number[] = []
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = searchRegex.exec(content)) !== null) {
|
||||
searchPositions.push(match.index)
|
||||
}
|
||||
|
||||
for (let i = 0; i < searchPositions.length; i++) {
|
||||
// Add separator between blocks
|
||||
if (i > 0) {
|
||||
result.lines.push({ type: "separator", content: "" })
|
||||
}
|
||||
|
||||
const start = searchPositions[i]
|
||||
const end = i < searchPositions.length - 1 ? searchPositions[i + 1] : content.length
|
||||
const blockContent = content.substring(start, end)
|
||||
|
||||
// Extract content after SEARCH marker
|
||||
const afterSearch = blockContent.substring(MARKERS.SEARCH_BLOCK.length).replace(/^\r?\n/, "")
|
||||
const separatorIndex = afterSearch.indexOf(MARKERS.SEARCH_SEPARATOR)
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
// Still streaming - only SEARCH block available
|
||||
const searchContent = afterSearch.trimEnd()
|
||||
for (const line of searchContent.split("\n")) {
|
||||
result.lines.push({ type: "remove", content: line })
|
||||
result.deletions++
|
||||
}
|
||||
} else {
|
||||
// Extract SEARCH block (deletions)
|
||||
const searchContent = afterSearch.substring(0, separatorIndex).replace(/\r?\n$/, "")
|
||||
for (const line of searchContent.split("\n")) {
|
||||
result.lines.push({ type: "remove", content: line })
|
||||
result.deletions++
|
||||
}
|
||||
|
||||
// Extract REPLACE block (additions)
|
||||
const afterSeparator = afterSearch.substring(separatorIndex + MARKERS.SEARCH_SEPARATOR.length).replace(/^\r?\n/, "")
|
||||
const replaceEndIndex = afterSeparator.indexOf(MARKERS.REPLACE_BLOCK)
|
||||
const replaceContent =
|
||||
replaceEndIndex !== -1
|
||||
? afterSeparator.substring(0, replaceEndIndex).replace(/\r?\n$/, "")
|
||||
: afterSeparator.trimEnd()
|
||||
|
||||
for (const line of replaceContent.split("\n")) {
|
||||
result.lines.push({ type: "add", content: line })
|
||||
result.additions++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ApplyPatch format into diff lines
|
||||
* Format: *** Begin Patch\n*** Update File: path\n+line\n-line\n*** End Patch
|
||||
*/
|
||||
function parseApplyPatchFormat(content: string): ParsedPatch {
|
||||
const result: ParsedPatch = { additions: 0, deletions: 0, lines: [] }
|
||||
|
||||
const beginIndex = content.indexOf(MARKERS.NEW_BEGIN)
|
||||
if (beginIndex === -1) return result
|
||||
|
||||
const endIndex = content.indexOf(MARKERS.NEW_END)
|
||||
const contentStart = beginIndex + MARKERS.NEW_BEGIN.length
|
||||
const contentEnd = endIndex !== -1 ? endIndex : content.length
|
||||
const patchContent = content.substring(contentStart, contentEnd).trim()
|
||||
|
||||
for (const line of patchContent.split("\n")) {
|
||||
// Skip file header lines
|
||||
if (line.match(/^\*\*\* (Add|Update|Delete) File:/)) continue
|
||||
if (line.trim() === "@@") continue
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
const hasSpace = line.startsWith("+ ")
|
||||
result.lines.push({ type: "add", content: hasSpace ? line.slice(2) : line.slice(1) })
|
||||
result.additions++
|
||||
} else if (line.startsWith("-")) {
|
||||
const hasSpace = line.startsWith("- ")
|
||||
result.lines.push({ type: "remove", content: hasSpace ? line.slice(2) : line.slice(1) })
|
||||
result.deletions++
|
||||
} else if (line.trim()) {
|
||||
// Context line
|
||||
result.lines.push({ type: "context", content: line })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool content into diff lines
|
||||
* Detects format and delegates to appropriate parser
|
||||
*/
|
||||
function parseToolContent(content: string): ParsedPatch {
|
||||
// Try SEARCH/REPLACE format first
|
||||
if (content.includes(MARKERS.SEARCH_BLOCK)) {
|
||||
return parseSearchReplaceFormat(content)
|
||||
}
|
||||
|
||||
// Try ApplyPatch format
|
||||
if (content.includes(MARKERS.NEW_BEGIN)) {
|
||||
return parseApplyPatchFormat(content)
|
||||
}
|
||||
|
||||
// Fallback: treat as new file (all additions)
|
||||
const lines = content.split("\n")
|
||||
return {
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
lines: lines.map((line) => ({ type: "add", content: line })),
|
||||
}
|
||||
}
|
||||
|
||||
// Dim diff colors similar to IDE diff views
|
||||
const DIFF_COLORS = {
|
||||
addBg: "rgb(35, 61, 41)", // dark muted green
|
||||
addFg: "rgb(156, 204, 122)", // light green text
|
||||
removeBg: "rgb(62, 36, 36)", // dark muted red
|
||||
removeFg: "rgb(224, 139, 139)", // light red/pink text
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Render a diff line with full-width background color highlighting
|
||||
* Uses Box with backgroundColor to handle wrapping properly
|
||||
*/
|
||||
const DiffLineRow: React.FC<{ line: DiffLine }> = ({ line }) => {
|
||||
if (line.type === "separator") {
|
||||
return <Text> </Text>
|
||||
}
|
||||
|
||||
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " "
|
||||
const content = prefix + line.content
|
||||
|
||||
switch (line.type) {
|
||||
case "add":
|
||||
return (
|
||||
<Box backgroundColor={DIFF_COLORS.addBg} width="100%">
|
||||
<Text color={DIFF_COLORS.addFg}>{content}</Text>
|
||||
</Box>
|
||||
)
|
||||
case "remove":
|
||||
return (
|
||||
<Box backgroundColor={DIFF_COLORS.removeBg} width="100%">
|
||||
<Text color={DIFF_COLORS.removeFg}>{content}</Text>
|
||||
</Box>
|
||||
)
|
||||
case "context":
|
||||
return <Text dimColor>{content}</Text>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffView component that renders file edits as a diff
|
||||
* Supports SEARCH/REPLACE format and ApplyPatch format
|
||||
*/
|
||||
export const DiffView: React.FC<DiffViewProps> = ({ content }) => {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = parseToolContent(content)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{parsed.lines.map((line, idx) => (
|
||||
<DiffLineRow key={idx} line={line} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -95,8 +95,8 @@ function parseInput(text: string, availableCommands?: string[]): Segment[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Always ensure at least one segment exists for stable cursor rendering
|
||||
if (segments.length === 0) {
|
||||
// If no segments (empty or whitespace only), add a single normal segment
|
||||
if (segments.length === 0 && text.length > 0) {
|
||||
segments.push({
|
||||
text: text,
|
||||
type: "normal",
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
|
||||
@@ -60,13 +59,23 @@ 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()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
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
|
||||
if (selectedKey.modelId) {
|
||||
config.actModeApiModelId = selectedKey.modelId
|
||||
config.planModeApiModelId = selectedKey.modelId
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
onComplete()
|
||||
@@ -6,7 +6,6 @@
|
||||
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,
|
||||
@@ -65,7 +64,6 @@ 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"
|
||||
@@ -107,7 +105,7 @@ export function hasStaticModels(provider: string): boolean {
|
||||
}
|
||||
|
||||
export function hasModelPicker(provider: string): boolean {
|
||||
return hasStaticModels(provider) || usesOpenRouterModels(provider) || provider === "oca"
|
||||
return hasStaticModels(provider) || usesOpenRouterModels(provider)
|
||||
}
|
||||
|
||||
export function getDefaultModelId(provider: string): string {
|
||||
@@ -134,7 +132,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [asyncModels, setAsyncModels] = useState<string[]>([])
|
||||
|
||||
// Fetch async models (OpenRouter or OCA) when needed
|
||||
// Fetch OpenRouter models when needed using shared core function
|
||||
useEffect(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
setIsLoading(true)
|
||||
@@ -147,23 +145,11 @@ 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) || provider === "oca") {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
return asyncModels
|
||||
}
|
||||
return getModelList(provider)
|
||||
@@ -194,7 +180,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
}
|
||||
|
||||
// If async fetch returned no models, render nothing
|
||||
if ((usesOpenRouterModels(provider) || provider === "oca") && modelList.length === 0) {
|
||||
if (usesOpenRouterModels(provider) && modelList.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -20,13 +20,11 @@ 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, isSubpage, children }) => {
|
||||
export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children }) => {
|
||||
const { columns } = useTerminalSize()
|
||||
const currentTabIndex = currentTab && tabs ? tabs.findIndex((t) => t.key === currentTab) : 0
|
||||
|
||||
@@ -37,7 +35,7 @@ export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, isSubpage
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text color="gray"> (Esc to {isSubpage ? "go back" : "close"})</Text>
|
||||
<Text color="gray"> (Esc to close)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tab bar if tabs are provided */}
|
||||
@@ -55,7 +53,7 @@ export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, isSubpage
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{!isSubpage && <Text color="gray"> (←/→)</Text>}
|
||||
<Text color="gray"> (←/→)</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,25 @@
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiConfiguration } from "@/shared/api"
|
||||
import { getProviderLabel, useValidProviders } from "../utils/providers"
|
||||
import { SearchableList, type SearchableListItem } from "./SearchableList"
|
||||
import { API_PROVIDERS_LIST, ApiConfiguration } from "@/shared/api"
|
||||
import providersData from "@/shared/providers/providers.json"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { getProviderLabel }
|
||||
// Create a lookup map from provider value to display label
|
||||
const providerLabels: Record<string, string> = Object.fromEntries(
|
||||
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
|
||||
)
|
||||
|
||||
// Get provider order from providers.json (same order as webview)
|
||||
const providerOrder: string[] = providersData.list.map((p: { value: string }) => p.value)
|
||||
|
||||
export function getProviderLabel(providerId: string): string {
|
||||
return providerLabels[providerId] || providerId
|
||||
}
|
||||
|
||||
export function getProviderOrder(): string[] {
|
||||
return providerOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is configured (has required credentials/settings)
|
||||
@@ -18,8 +31,7 @@ export { getProviderLabel }
|
||||
function isProviderConfigured(providerId: string, config: ApiConfiguration): boolean {
|
||||
switch (providerId) {
|
||||
case "cline":
|
||||
// Check if user has Cline API key or Cline account auth data stored
|
||||
return !!(config.clineApiKey ?? config["cline:clineAccountId"])
|
||||
return true // Always available
|
||||
case "anthropic":
|
||||
return !!config.apiKey
|
||||
case "openrouter":
|
||||
@@ -33,8 +45,7 @@ function isProviderConfigured(providerId: string, config: ApiConfiguration): boo
|
||||
case "openai-native":
|
||||
return !!config.openAiNativeApiKey
|
||||
case "openai-codex":
|
||||
// OpenAI Codex uses OAuth with credentials stored as JSON blob
|
||||
return !!(config as Record<string, unknown>)["openai-codex-oauth-credentials"]
|
||||
return !!config.openAiCodexRefreshToken
|
||||
case "deepseek":
|
||||
return !!config.deepSeekApiKey
|
||||
case "xai":
|
||||
@@ -125,16 +136,18 @@ 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
|
||||
// Use providers.json order, filtered to only available providers
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
return sorted.map((providerId: string) => ({
|
||||
const availableProviders = new Set(API_PROVIDERS_LIST)
|
||||
const sorted = providerOrder.filter((p) => availableProviders.has(p))
|
||||
|
||||
return sorted.map((providerId) => ({
|
||||
id: providerId,
|
||||
label: getProviderLabel(providerId),
|
||||
suffix: isProviderConfigured(providerId, apiConfig) ? "(Configured)" : undefined,
|
||||
}))
|
||||
}, [apiConfig, sorted])
|
||||
}, [apiConfig])
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX transform (tsconfig uses jsx: react)
|
||||
import React, { useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
+127
-463
@@ -5,41 +5,26 @@
|
||||
|
||||
import type { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import type { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { getProviderModelIdKey, isSettingsKey, ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { ProviderToApiKeyMap } from "@shared/storage"
|
||||
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 { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { Checkbox } from "./Checkbox"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
getFeaturedModelAtIndex,
|
||||
getFeaturedModelMaxIndex,
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { LanguagePicker } from "./LanguagePicker"
|
||||
import { hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OrganizationPicker } from "./OrganizationPicker"
|
||||
import { Panel, PanelTab } from "./Panel"
|
||||
import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
|
||||
@@ -47,8 +32,7 @@ import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
|
||||
interface SettingsPanelContentProps {
|
||||
onClose: () => void
|
||||
controller?: Controller
|
||||
initialMode?: "model-picker" | "featured-models"
|
||||
initialModelKey?: "actModelId" | "planModelId"
|
||||
initialMode?: "model-picker"
|
||||
}
|
||||
|
||||
type SettingsTab = "api" | "auto-approve" | "features" | "other" | "account"
|
||||
@@ -56,25 +40,13 @@ type SettingsTab = "api" | "auto-approve" | "features" | "other" | "account"
|
||||
interface ListItem {
|
||||
key: string
|
||||
label: string
|
||||
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer" | "action" | "cycle"
|
||||
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer" | "action"
|
||||
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" },
|
||||
@@ -85,47 +57,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",
|
||||
stateKey: "useAutoCondense" as const,
|
||||
default: false,
|
||||
label: "Auto-condense",
|
||||
description: "Automatically summarize long conversations",
|
||||
},
|
||||
webTools: {
|
||||
stateKey: "clineWebToolsEnabled",
|
||||
stateKey: "clineWebToolsEnabled" as const,
|
||||
default: true,
|
||||
label: "Web tools",
|
||||
description: "Enable web search and fetch tools",
|
||||
},
|
||||
strictPlanMode: {
|
||||
stateKey: "strictPlanModeEnabled",
|
||||
stateKey: "strictPlanModeEnabled" as const,
|
||||
default: true,
|
||||
label: "Strict plan mode",
|
||||
description: "Require explicit mode switching",
|
||||
},
|
||||
nativeToolCall: {
|
||||
stateKey: "nativeToolCallEnabled",
|
||||
stateKey: "nativeToolCallEnabled" as const,
|
||||
default: true,
|
||||
label: "Native tool call",
|
||||
description: "Use model's native tool calling API",
|
||||
},
|
||||
parallelToolCalling: {
|
||||
stateKey: "enableParallelToolCalling",
|
||||
stateKey: "enableParallelToolCalling" as const,
|
||||
default: false,
|
||||
label: "Parallel tool calling",
|
||||
description: "Allow multiple tools in a single response",
|
||||
},
|
||||
doubleCheckCompletion: {
|
||||
stateKey: "doubleCheckCompletionEnabled",
|
||||
skillsEnabled: {
|
||||
stateKey: "skillsEnabled" as const,
|
||||
default: false,
|
||||
label: "Double-check completion",
|
||||
description: "Reject first completion attempt and require re-verification",
|
||||
label: "Skills",
|
||||
description: "Enable reusable agent instructions",
|
||||
},
|
||||
autoApproveAll: {
|
||||
stateKey: "yoloModeToggled" as const,
|
||||
default: false,
|
||||
label: "Auto-approve all",
|
||||
description: "Automatically approve all actions (Shift+Tab)",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -141,12 +113,7 @@ function formatBalance(balance: number | null): string {
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
onClose,
|
||||
controller,
|
||||
initialMode,
|
||||
initialModelKey,
|
||||
}) => {
|
||||
export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onClose, controller, initialMode }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
@@ -156,16 +123,13 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isPickingModel, setIsPickingModel] = useState(initialMode === "model-picker")
|
||||
const [pickingModelKey, setPickingModelKey] = useState<"actModelId" | "planModelId" | null>(
|
||||
initialMode ? (initialModelKey ?? "actModelId") : null,
|
||||
initialMode === "model-picker" ? "actModelId" : null,
|
||||
)
|
||||
const [isPickingFeaturedModel, setIsPickingFeaturedModel] = useState(initialMode === "featured-models")
|
||||
const [featuredModelIndex, setFeaturedModelIndex] = useState(0)
|
||||
const [isPickingProvider, setIsPickingProvider] = useState(false)
|
||||
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
|
||||
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
|
||||
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
|
||||
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
|
||||
const [isShowingOcaEmployeeCheck, setIsShowingOcaEmployeeCheck] = useState(false)
|
||||
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
|
||||
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
|
||||
const [apiKeyValue, setApiKeyValue] = useState("")
|
||||
@@ -175,11 +139,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {}
|
||||
for (const [key, config] of Object.entries(FEATURE_SETTINGS)) {
|
||||
if (isSettingsKey(config.stateKey)) {
|
||||
initial[key] = stateManager.getGlobalSettingsKey(config.stateKey)
|
||||
} else {
|
||||
initial[key] = stateManager.getGlobalStateKey(config.stateKey)
|
||||
}
|
||||
initial[key] = stateManager.getGlobalSettingsKey(config.stateKey) ?? config.default
|
||||
}
|
||||
return initial as Record<FeatureKey, boolean>
|
||||
})
|
||||
@@ -195,12 +155,6 @@ 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>(() => {
|
||||
@@ -226,50 +180,12 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [accountChecked, setAccountChecked] = useState(false) // Tracks if we've already checked auth
|
||||
|
||||
// Get current provider and model info
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const [provider, setProvider] = useState<string>(
|
||||
() =>
|
||||
stateManager.getApiConfiguration().actModeApiProvider ||
|
||||
stateManager.getApiConfiguration().planModeApiProvider ||
|
||||
"not configured",
|
||||
() => apiConfig.actModeApiProvider || apiConfig.planModeApiProvider || "not configured",
|
||||
)
|
||||
// Refresh trigger to force re-reading model IDs from state
|
||||
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()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
if (!actProvider && !planProvider) {
|
||||
return { actModelId: "", planModelId: "" }
|
||||
}
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider, "plan") : null
|
||||
return {
|
||||
actModelId: actKey ? (stateManager.getGlobalSettingsKey(actKey) as string) || "" : "",
|
||||
planModelId: planKey ? (stateManager.getGlobalSettingsKey(planKey) as string) || "" : "",
|
||||
}
|
||||
}, [modelRefreshKey, stateManager])
|
||||
const actModelId = (stateManager.getGlobalSettingsKey("actModeApiModelId") as string) || ""
|
||||
const planModelId = (stateManager.getGlobalSettingsKey("planModeApiModelId") as string) || ""
|
||||
|
||||
// Toggle a feature setting
|
||||
const toggleFeature = useCallback(
|
||||
@@ -311,21 +227,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const accountService = ClineAccountService.getInstance()
|
||||
|
||||
// Fetch fresh organization info from server (like webview's getUserOrganizations RPC)
|
||||
// Don't use authService.getUserOrganizations() as it returns cached data
|
||||
const organizations = await accountService.fetchUserOrganizationsRPC()
|
||||
let activeOrgId: string | undefined
|
||||
// Get organization info
|
||||
const organizations = authService.getUserOrganizations()
|
||||
if (organizations) {
|
||||
setAccountOrganizations(organizations)
|
||||
const activeOrg = organizations.find((org) => org.active)
|
||||
setAccountOrganization(activeOrg || null)
|
||||
activeOrgId = activeOrg?.organizationId
|
||||
}
|
||||
|
||||
// Fetch credit balance
|
||||
try {
|
||||
const accountService = ClineAccountService.getInstance()
|
||||
const activeOrgId = authService.getActiveOrganizationId()
|
||||
|
||||
if (activeOrgId) {
|
||||
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
|
||||
if (orgBalance?.balance !== undefined) {
|
||||
@@ -385,8 +299,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setIsPickingOrganization(false)
|
||||
try {
|
||||
await ClineAccountService.getInstance().switchAccount(orgId || undefined)
|
||||
// Refetch fresh org data from server
|
||||
await fetchAccountInfo()
|
||||
// Refetch to get updated auth info with new active org
|
||||
await AuthService.getInstance(controller).restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
fetchAccountInfo()
|
||||
} catch {
|
||||
// Error switching organization
|
||||
}
|
||||
@@ -419,7 +334,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setAccountChecked(false) // Reset so fetchAccountInfo can run
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setProvider("cline")
|
||||
refreshModelIds()
|
||||
fetchAccountInfo()
|
||||
}
|
||||
}
|
||||
@@ -433,12 +347,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
|
||||
// Build items list based on current tab
|
||||
const items: ListItem[] = useMemo(() => {
|
||||
// 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
|
||||
// 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
|
||||
|
||||
switch (currentTab) {
|
||||
case "api":
|
||||
@@ -462,7 +373,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
...(showActThinkingOption
|
||||
...(showThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
@@ -472,16 +383,6 @@ 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",
|
||||
@@ -489,7 +390,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: planModelId || "not set",
|
||||
},
|
||||
...(showPlanThinkingOption
|
||||
...(showThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "planThinkingEnabled",
|
||||
@@ -499,16 +400,6 @@ 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: "" },
|
||||
]
|
||||
: [
|
||||
@@ -518,7 +409,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
...(showActThinkingOption
|
||||
...(showThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
@@ -528,16 +419,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showActReasoningEffort
|
||||
? [
|
||||
{
|
||||
key: "actReasoningEffort",
|
||||
label: "Reasoning effort",
|
||||
type: "cycle" as const,
|
||||
value: actReasoningEffort,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
{
|
||||
key: "separateModels",
|
||||
@@ -564,7 +445,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
key: parentKey,
|
||||
label: parentLabel,
|
||||
type: "checkbox",
|
||||
value: actions[parentKey as keyof typeof actions] ?? false,
|
||||
value: actions[parentKey as keyof typeof actions],
|
||||
description: parentDesc,
|
||||
})
|
||||
if (actions[parentKey as keyof typeof actions]) {
|
||||
@@ -648,11 +529,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
key: "telemetry",
|
||||
label: "Error/usage reporting",
|
||||
type: "checkbox",
|
||||
value: telemetry !== "disabled",
|
||||
value: telemetry === "enabled",
|
||||
description: "Help improve Cline by sending anonymous usage data",
|
||||
},
|
||||
{ key: "separator", label: "", type: "separator", value: "" },
|
||||
{ key: "version", label: "", type: "readonly", value: `Cline v${CLI_VERSION}` },
|
||||
]
|
||||
|
||||
case "account":
|
||||
@@ -700,8 +579,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
separateModels,
|
||||
actThinkingEnabled,
|
||||
planThinkingEnabled,
|
||||
actReasoningEffort,
|
||||
planReasoningEffort,
|
||||
autoApproveSettings,
|
||||
features,
|
||||
preferredLanguage,
|
||||
@@ -735,33 +612,6 @@ 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]
|
||||
@@ -785,15 +635,6 @@ 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") {
|
||||
@@ -803,13 +644,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
// For model ID fields, check if we should use the model picker
|
||||
if ((item.key === "actModelId" || item.key === "planModelId") && hasModelPicker(provider)) {
|
||||
setPickingModelKey(item.key as "actModelId" | "planModelId")
|
||||
// For Cline provider, show featured models first
|
||||
if (provider === "cline") {
|
||||
setFeaturedModelIndex(0)
|
||||
setIsPickingFeaturedModel(true)
|
||||
} else {
|
||||
setIsPickingModel(true)
|
||||
}
|
||||
setIsPickingModel(true)
|
||||
return
|
||||
}
|
||||
// For language field, use the language picker
|
||||
@@ -842,25 +677,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
stateManager.setGlobalState("planActSeparateModelsSetting", newValue)
|
||||
// When disabling separate models, sync plan model to act model
|
||||
if (!newValue) {
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
if (actProvider) {
|
||||
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)
|
||||
const actModel = stateManager.getGlobalSettingsKey("actModeApiModelId")
|
||||
stateManager.setGlobalState("planModeApiModelId", actModel)
|
||||
}
|
||||
|
||||
rebuildTaskApi()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -868,19 +687,23 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
if (item.key === "actThinkingEnabled") {
|
||||
setActThinkingEnabled(newValue)
|
||||
stateManager.setGlobalState("actModeThinkingBudgetTokens", newValue ? 1024 : 0)
|
||||
if (!separateModels) {
|
||||
setPlanThinkingEnabled(newValue)
|
||||
stateManager.setGlobalState("planModeThinkingBudgetTokens", newValue ? 1024 : 0)
|
||||
}
|
||||
// Rebuild API handler to apply thinking budget change
|
||||
rebuildTaskApi()
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (item.key === "planThinkingEnabled") {
|
||||
setPlanThinkingEnabled(newValue)
|
||||
stateManager.setGlobalState("planModeThinkingBudgetTokens", newValue ? 1024 : 0)
|
||||
// Rebuild API handler to apply thinking budget change
|
||||
rebuildTaskApi()
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -889,11 +712,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const newTelemetry: TelemetrySetting = newValue ? "enabled" : "disabled"
|
||||
setTelemetry(newTelemetry)
|
||||
stateManager.setGlobalState("telemetrySetting", newTelemetry)
|
||||
// Flush synchronously before continuing - must complete before app can exit
|
||||
void stateManager.flushPendingState().then(() => {
|
||||
// Update telemetry providers to respect the new setting
|
||||
controller?.updateTelemetrySetting(newTelemetry)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -937,78 +755,25 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
handleClineLogin,
|
||||
handleClineLogout,
|
||||
accountOrganizations,
|
||||
separateModels,
|
||||
actReasoningEffort,
|
||||
planReasoningEffort,
|
||||
rebuildTaskApi,
|
||||
setReasoningEffortForMode,
|
||||
])
|
||||
|
||||
// Handle model selection from picker
|
||||
const handleModelSelect = useCallback(
|
||||
async (modelId: string) => {
|
||||
(modelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
const providerForSelection = separateModels
|
||||
? pickingModelKey === "actModelId"
|
||||
? actProvider
|
||||
: planProvider
|
||||
: actProvider || planProvider
|
||||
if (!providerForSelection) return
|
||||
// Use provider-specific model ID keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
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: ModelInfo | undefined
|
||||
if (providerForSelection === "cline" || providerForSelection === "openrouter") {
|
||||
const openRouterModels = await controller?.readOpenRouterModels()
|
||||
modelInfo = openRouterModels?.[modelId]
|
||||
}
|
||||
|
||||
if (separateModels) {
|
||||
// Only update the selected mode's model
|
||||
const stateKey = pickingModelKey === "actModelId" ? actKey : planKey
|
||||
if (stateKey) stateManager.setGlobalState(stateKey, modelId)
|
||||
// Set model info for the selected mode
|
||||
if (modelInfo) {
|
||||
const infoKey =
|
||||
pickingModelKey === "actModelId" ? "actModeOpenRouterModelInfo" : "planModeOpenRouterModelInfo"
|
||||
stateManager.setGlobalState(infoKey, modelInfo)
|
||||
}
|
||||
const stateKey = pickingModelKey === "actModelId" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
stateManager.setGlobalState(stateKey, modelId)
|
||||
} else {
|
||||
// Update both modes to keep them in sync
|
||||
if (actKey) stateManager.setGlobalState(actKey, modelId)
|
||||
if (planKey) stateManager.setGlobalState(planKey, modelId)
|
||||
// Set model info for both modes
|
||||
if (modelInfo) {
|
||||
stateManager.setGlobalState("actModeOpenRouterModelInfo", modelInfo)
|
||||
stateManager.setGlobalState("planModeOpenRouterModelInfo", modelInfo)
|
||||
}
|
||||
stateManager.setGlobalState("actModeApiModelId", modelId)
|
||||
stateManager.setGlobalState("planModeApiModelId", modelId)
|
||||
}
|
||||
|
||||
// Flush pending state to ensure model ID is persisted
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler if there's an active task
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const freshApiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...freshApiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
|
||||
refreshModelIds()
|
||||
setIsPickingModel(false)
|
||||
setPickingModelKey(null)
|
||||
|
||||
// If opened from /models command, close the entire settings panel
|
||||
if (initialMode) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose],
|
||||
[pickingModelKey, separateModels, stateManager],
|
||||
)
|
||||
|
||||
// Handle language selection from picker
|
||||
@@ -1039,7 +804,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
// Success - apply provider config
|
||||
await applyProviderConfig({ providerId: "openai-codex", controller })
|
||||
setProvider("openai-codex")
|
||||
refreshModelIds()
|
||||
setIsWaitingForCodexAuth(false)
|
||||
} catch (error) {
|
||||
openAiCodexOAuthManager.cancelAuthorizationFlow()
|
||||
@@ -1048,8 +812,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
// Handle provider selection from picker
|
||||
const handleProviderSelect = useCallback(
|
||||
async (providerId: string) => {
|
||||
(providerId: string) => {
|
||||
// Special handling for Cline - uses OAuth (but skip if already logged in)
|
||||
if (providerId === "cline") {
|
||||
setIsPickingProvider(false)
|
||||
@@ -1057,9 +822,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const authInfo = AuthService.getInstance(controller).getInfo()
|
||||
if (authInfo?.user?.email) {
|
||||
// Already logged in - just set the provider
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
applyProviderConfig({ providerId: "cline", controller })
|
||||
setProvider("cline")
|
||||
refreshModelIds()
|
||||
} else {
|
||||
// Not logged in - trigger OAuth
|
||||
handleClineLogin()
|
||||
@@ -1074,22 +838,6 @@ 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)
|
||||
@@ -1099,7 +847,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
}
|
||||
|
||||
// Check if this provider needs an API key
|
||||
const keyField = ProviderToApiKeyMap[providerId as ApiProvider]
|
||||
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
// Provider needs an API key - go to API key entry mode
|
||||
// Pre-fill with existing key if configured
|
||||
@@ -1112,13 +860,12 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setIsEnteringApiKey(true)
|
||||
} else {
|
||||
// Provider doesn't need an API key (rare) - just set it
|
||||
await applyProviderConfig({ providerId, controller })
|
||||
applyProviderConfig({ providerId, controller })
|
||||
setProvider(providerId)
|
||||
refreshModelIds()
|
||||
setIsPickingProvider(false)
|
||||
}
|
||||
},
|
||||
[stateManager, startCodexAuth, handleClineLogin, startOcaAuth, isOcaAuthenticated, controller, refreshModelIds],
|
||||
[stateManager, startCodexAuth, handleClineLogin, controller],
|
||||
)
|
||||
|
||||
// Handle API key submission after provider selection
|
||||
@@ -1130,27 +877,53 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
|
||||
await applyProviderConfig({ providerId: pendingProvider, apiKey: submittedValue.trim(), controller })
|
||||
setProvider(pendingProvider)
|
||||
refreshModelIds()
|
||||
setIsEnteringApiKey(false)
|
||||
setPendingProvider(null)
|
||||
setApiKeyValue("")
|
||||
},
|
||||
[pendingProvider, controller, refreshModelIds],
|
||||
[pendingProvider, controller],
|
||||
)
|
||||
|
||||
// Handle Bedrock configuration complete
|
||||
const handleBedrockComplete = useCallback(
|
||||
(bedrockConfig: BedrockConfig) => {
|
||||
// Update UI state first for responsiveness
|
||||
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) {
|
||||
config.actModeApiModelId = defaultModelId
|
||||
config.planModeApiModelId = 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
|
||||
setProvider("bedrock")
|
||||
refreshModelIds()
|
||||
setIsConfiguringBedrock(false)
|
||||
setPendingProvider(null)
|
||||
|
||||
// Apply config and rebuild API handler in background
|
||||
applyBedrockConfig({ bedrockConfig, controller })
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
},
|
||||
[controller, refreshModelIds],
|
||||
[stateManager, controller],
|
||||
)
|
||||
|
||||
// Handle saving edited value
|
||||
@@ -1160,26 +933,17 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
|
||||
switch (item.key) {
|
||||
case "actModelId":
|
||||
case "planModelId": {
|
||||
// Use provider-specific model ID keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
if (!actProvider && !planProvider) break
|
||||
const actKey = actProvider ? getProviderModelIdKey(actProvider, "act") : null
|
||||
const planKey = planProvider ? getProviderModelIdKey(planProvider, "plan") : null
|
||||
|
||||
case "planModelId":
|
||||
if (separateModels) {
|
||||
// Only update the selected mode's model
|
||||
const stateKey = item.key === "actModelId" ? actKey : planKey
|
||||
if (stateKey) stateManager.setGlobalState(stateKey, editValue || undefined)
|
||||
const stateKey = item.key === "actModelId" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
stateManager.setGlobalState(stateKey, editValue || undefined)
|
||||
} else {
|
||||
// Update both modes to keep them in sync
|
||||
if (actKey) stateManager.setGlobalState(actKey, editValue || undefined)
|
||||
if (planKey) stateManager.setGlobalState(planKey, editValue || undefined)
|
||||
stateManager.setGlobalState("actModeApiModelId", editValue || undefined)
|
||||
stateManager.setGlobalState("planModeApiModelId", editValue || undefined)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "language":
|
||||
setPreferredLanguage(editValue)
|
||||
stateManager.setGlobalState("preferredLanguage", editValue)
|
||||
@@ -1240,47 +1004,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Featured model picker mode (Cline provider)
|
||||
if (isPickingFeaturedModel) {
|
||||
const maxIndex = getFeaturedModelMaxIndex()
|
||||
|
||||
if (key.escape) {
|
||||
setIsPickingFeaturedModel(false)
|
||||
setPickingModelKey(null)
|
||||
// If opened from /models command, close the entire settings panel
|
||||
if (initialMode) {
|
||||
onClose()
|
||||
}
|
||||
} else if (key.upArrow) {
|
||||
setFeaturedModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setFeaturedModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (isBrowseAllSelected(featuredModelIndex)) {
|
||||
// Switch to full ModelPicker
|
||||
setIsPickingFeaturedModel(false)
|
||||
setIsPickingModel(true)
|
||||
} else {
|
||||
const selectedModel = getFeaturedModelAtIndex(featuredModelIndex)
|
||||
if (selectedModel && pickingModelKey) {
|
||||
handleModelSelect(selectedModel.id)
|
||||
setIsPickingFeaturedModel(false)
|
||||
setPickingModelKey(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Model picker mode - escape to close, input is handled by ModelPicker
|
||||
if (isPickingModel) {
|
||||
if (key.escape) {
|
||||
setIsPickingModel(false)
|
||||
setPickingModelKey(null)
|
||||
// If opened from /models command, close the entire settings panel
|
||||
if (initialMode) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1324,14 +1052,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// OCA OAuth waiting mode - escape to cancel
|
||||
if (isWaitingForOcaAuth) {
|
||||
if (key.escape) {
|
||||
cancelOcaAuth()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (key.escape) {
|
||||
setIsEditing(false)
|
||||
@@ -1376,7 +1096,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
|
||||
)
|
||||
|
||||
// Render content
|
||||
@@ -1463,17 +1183,6 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (isPickingFeaturedModel && pickingModelKey) {
|
||||
const label = pickingModelKey === "actModelId" ? "Model ID (Act)" : "Model ID (Plan)"
|
||||
return (
|
||||
<FeaturedModelPicker
|
||||
helpText="Arrows to navigate, Enter to select, Esc to cancel"
|
||||
selectedIndex={featuredModelIndex}
|
||||
title={`Select: ${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPickingModel && pickingModelKey) {
|
||||
const label = pickingModelKey === "actModelId" ? "Model ID (Act)" : "Model ID (Plan)"
|
||||
return (
|
||||
@@ -1552,38 +1261,6 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
// Account tab - loading state
|
||||
if (currentTab === "account" && isAccountLoading) {
|
||||
return (
|
||||
@@ -1640,8 +1317,26 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Show special message for auto-approve tab when auto-approve all is enabled
|
||||
if (currentTab === "auto-approve" && features.autoApproveAll) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="green">Auto-approve all is enabled (Shift+Tab)</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">All tool calls are automatically approved. When disabled, the settings</Text>
|
||||
</Box>
|
||||
<Text color="gray">below control which actions are auto-approved.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{currentTab === "auto-approve" && !features.autoApproveAll && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color="gray">These actions are auto-approved when 'Auto-approve all' is disabled:</Text>
|
||||
</Box>
|
||||
)}
|
||||
{items.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
|
||||
@@ -1701,28 +1396,13 @@ 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}>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
{item.label && <Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}: </Text>}
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}: </Text>
|
||||
<Text color={item.type === "readonly" ? "gray" : COLORS.primaryBlue}>
|
||||
{typeof item.value === "string" ? item.value : String(item.value)}
|
||||
</Text>
|
||||
@@ -1734,24 +1414,8 @@ 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 ||
|
||||
isEditing
|
||||
|
||||
return (
|
||||
<Panel currentTab={currentTab} isSubpage={isSubpage} label="Settings" tabs={TABS}>
|
||||
<Panel currentTab={currentTab} label="Settings" tabs={TABS}>
|
||||
{renderContent()}
|
||||
</Panel>
|
||||
)
|
||||
+11
-20
@@ -7,7 +7,6 @@ import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
@@ -17,16 +16,6 @@ interface SlashCommandMenuProps {
|
||||
}
|
||||
|
||||
export const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ commands, selectedIndex, query }) => {
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
const contentWidth = Math.max(10, terminalWidth - 4)
|
||||
|
||||
const truncateText = (text: string, maxLength: number): string => {
|
||||
if (maxLength <= 0) return ""
|
||||
if (text.length <= maxLength) return text
|
||||
if (maxLength <= 3) return text.slice(0, maxLength)
|
||||
return text.slice(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
@@ -44,17 +33,19 @@ export const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ commands, se
|
||||
const isSelected = startIndex + idx === selectedIndex
|
||||
// Only show description for default commands (not workflows)
|
||||
const showDescription = cmd.section === "default" || !cmd.section
|
||||
const commandPrefix = `${isSelected ? "❯" : " "} /${cmd.name}`
|
||||
const truncatedCommand = truncateText(commandPrefix, contentWidth)
|
||||
const descriptionText = showDescription && cmd.description ? ` - ${cmd.description}` : ""
|
||||
const fullLine = truncateText(truncatedCommand + descriptionText, contentWidth)
|
||||
const commandText = fullLine.slice(0, Math.min(fullLine.length, truncatedCommand.length))
|
||||
const descText = fullLine.slice(commandText.length)
|
||||
|
||||
return (
|
||||
<Box flexWrap="nowrap" key={cmd.name} width={contentWidth}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>{commandText}</Text>
|
||||
{descText && <Text color="gray">{descText}</Text>}
|
||||
<Box flexDirection="column" key={cmd.name}>
|
||||
<Box>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "} /{cmd.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{showDescription && cmd.description && (
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="gray">{cmd.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user